mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Implement renderer API for replacing layout components
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
'@fumadocs/base-ui': minor
|
||||
---
|
||||
|
||||
Implement renderer API for replacing layout components, deprecate old options
|
||||
@@ -4,10 +4,11 @@ import { type ComponentProps, createContext, type ReactNode, use, useMemo } from
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useSidebar } from '@/components/sidebar/base';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { isTabActive } from '@/components/sidebar/tabs/dropdown';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import type { SidebarTab } from '@/components/sidebar/tabs';
|
||||
import { isTabActive } from '@/components/sidebar/tabs/dropdown';
|
||||
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
|
||||
import { renderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
export const LayoutContext = createContext<{
|
||||
isNavTransparent: boolean;
|
||||
@@ -56,33 +57,38 @@ export function LayoutHeader(props: ComponentProps<'header'>) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutBody({ className, style, children, ...props }: ComponentProps<'div'>) {
|
||||
export function LayoutBody({
|
||||
_,
|
||||
children,
|
||||
}: {
|
||||
_: Renderer<ComponentProps<'div'>>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const render = renderer(_, 'div');
|
||||
const { collapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<div
|
||||
id="nd-docs-layout"
|
||||
className={cn(
|
||||
'grid transition-[grid-template-columns] overflow-x-clip min-h-(--fd-docs-height) [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
|
||||
className,
|
||||
)}
|
||||
data-sidebar-collapsed={collapsed}
|
||||
style={
|
||||
{
|
||||
return render?.(
|
||||
(t) =>
|
||||
({
|
||||
id: 'nd-docs-layout',
|
||||
'data-sidebar-collapsed': collapsed,
|
||||
children,
|
||||
...t,
|
||||
className: cn(
|
||||
'grid transition-[grid-template-columns] overflow-x-clip min-h-(--fd-docs-height) [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
|
||||
t?.className,
|
||||
),
|
||||
style: {
|
||||
gridTemplate: `"sidebar sidebar header toc toc"
|
||||
"sidebar sidebar toc-popover toc toc"
|
||||
"sidebar sidebar main toc toc" 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-width) - var(--fd-toc-width))) var(--fd-toc-width) minmax(min-content, 1fr)`,
|
||||
"sidebar sidebar toc-popover toc toc"
|
||||
"sidebar sidebar main toc toc" 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-width) - var(--fd-toc-width))) var(--fd-toc-width) minmax(min-content, 1fr)`,
|
||||
'--fd-docs-row-1': 'var(--fd-banner-height, 0px)',
|
||||
'--fd-docs-row-2': 'calc(var(--fd-docs-row-1) + var(--fd-header-height))',
|
||||
'--fd-docs-row-3': 'calc(var(--fd-docs-row-2) + var(--fd-toc-popover-height))',
|
||||
'--fd-sidebar-col': collapsed ? '0px' : 'var(--fd-sidebar-width)',
|
||||
...style,
|
||||
} as object
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
...t?.style,
|
||||
},
|
||||
}) as ComponentProps<'div'>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
SidebarTrigger,
|
||||
SidebarViewport,
|
||||
} from './sidebar';
|
||||
import { type BaseLayoutProps, renderTitleNav, useLinkItems } from '@/layouts/shared';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
parseLayoutProps,
|
||||
renderTitleNav,
|
||||
useLinkItems,
|
||||
} from '@/layouts/shared';
|
||||
import { LinkItem } from '@/utils/link-item';
|
||||
import { LanguageToggle, LanguageToggleText } from '@/layouts/shared/language-toggle';
|
||||
import { LayoutBody, LayoutContextProvider, LayoutHeader, LayoutTabs } from './client';
|
||||
@@ -23,16 +28,18 @@ import { LargeSearchToggle, SearchToggle } from '@/layouts/shared/search-toggle'
|
||||
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
|
||||
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
|
||||
import { SidebarTabsDropdown, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
|
||||
import { type Renderer, renderer } from '@/utils/renderer';
|
||||
|
||||
export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
|
||||
sidebar?: SidebarOptions;
|
||||
|
||||
tabMode?: 'top' | 'auto';
|
||||
SidebarTrigger?: Renderer<ComponentProps<'button'>>;
|
||||
Container?: Renderer<ComponentProps<'div'>>;
|
||||
|
||||
/**
|
||||
* Props for the `div` container
|
||||
* @deprecated use `Container` instead.
|
||||
*/
|
||||
containerProps?: HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
@@ -61,23 +68,35 @@ interface SidebarOptions
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
export function DocsLayout({
|
||||
nav: { transparentMode, ...nav } = {},
|
||||
sidebar: {
|
||||
tabs: sidebarTabs,
|
||||
enabled: sidebarEnabled = true,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
searchToggle = {},
|
||||
themeSwitch = {},
|
||||
tabMode = 'auto',
|
||||
i18n = false,
|
||||
children,
|
||||
tree,
|
||||
...props
|
||||
}: DocsLayoutProps) {
|
||||
export function DocsLayout(_: DocsLayoutProps) {
|
||||
const {
|
||||
nav: { transparentMode, ...nav } = {},
|
||||
sidebar: {
|
||||
tabs: sidebarTabs,
|
||||
enabled: sidebarEnabled = true,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
SearchToggle: SearchToggleRenderer,
|
||||
LargeSearchToggle: LargeSearchToggleRenderer,
|
||||
ThemeSwitch,
|
||||
containerProps,
|
||||
Container = containerProps ?? true,
|
||||
tabMode = 'auto',
|
||||
LanguageSwitch,
|
||||
SidebarTrigger: SidebarTriggerRenderer = sidebarEnabled,
|
||||
children,
|
||||
tree,
|
||||
...props
|
||||
} = parseLayoutProps<DocsLayoutProps>(_);
|
||||
|
||||
const renderSearchToggle = renderer(SearchToggleRenderer, SearchToggle);
|
||||
const renderLargeSearchToggle = renderer(LargeSearchToggleRenderer, LargeSearchToggle);
|
||||
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
|
||||
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
|
||||
const renderSidebarTrigger = renderer(SidebarTriggerRenderer, SidebarTrigger);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(sidebarTabs)) {
|
||||
return sidebarTabs;
|
||||
@@ -131,20 +150,21 @@ export function DocsLayout({
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
</div>
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ?? <LargeSearchToggle hideIfDisabled />)}
|
||||
{renderLargeSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
}))}
|
||||
{tabs.length > 0 && tabMode === 'auto' && <SidebarTabsDropdown options={tabs} />}
|
||||
{banner}
|
||||
</div>
|
||||
{viewport}
|
||||
{(i18n || iconLinks.length > 0 || themeSwitch?.enabled !== false || footer) && (
|
||||
{(renderLanguageSwitch || iconLinks.length > 0 || renderThemeSwitch || 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>
|
||||
)}
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: <Languages className="size-4.5" />,
|
||||
...o,
|
||||
}))}
|
||||
{iconLinks.map((item, i) => (
|
||||
<LinkItem
|
||||
key={i}
|
||||
@@ -155,10 +175,7 @@ export function DocsLayout({
|
||||
{item.icon}
|
||||
</LinkItem>
|
||||
))}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle className="ms-auto p-0" mode={themeSwitch.mode} />
|
||||
))}
|
||||
{renderThemeSwitch?.((t) => ({ ...t, className: cn('ms-auto p-0', t?.className) }))}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
@@ -185,25 +202,28 @@ export function DocsLayout({
|
||||
</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(
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: (
|
||||
<>
|
||||
<Languages className="size-4.5" />
|
||||
<LanguageToggleText />
|
||||
</>
|
||||
),
|
||||
...o,
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => ({ ...t, className: cn('p-0', t?.className) }))}
|
||||
{renderSidebarTrigger?.((t) => ({
|
||||
children: <SidebarIcon />,
|
||||
...t,
|
||||
className: cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'p-2',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarTrigger>
|
||||
'p-2',
|
||||
t?.className,
|
||||
),
|
||||
}))}
|
||||
</div>
|
||||
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
|
||||
{banner}
|
||||
@@ -219,7 +239,7 @@ export function DocsLayout({
|
||||
<TreeContextProvider tree={tree}>
|
||||
<LayoutContextProvider navTransparentMode={transparentMode}>
|
||||
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
|
||||
<LayoutBody {...props.containerProps}>
|
||||
<LayoutBody _={Container}>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
<LayoutHeader
|
||||
@@ -230,23 +250,23 @@ export function DocsLayout({
|
||||
className: 'inline-flex items-center gap-2.5 font-semibold',
|
||||
})}
|
||||
<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>
|
||||
)}
|
||||
{renderSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn('p-2', o?.className),
|
||||
}))}
|
||||
{renderSidebarTrigger?.((t) => ({
|
||||
children: <SidebarIcon />,
|
||||
...t,
|
||||
className: cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'p-2',
|
||||
t?.className,
|
||||
),
|
||||
}))}
|
||||
</LayoutHeader>
|
||||
))}
|
||||
{sidebarEnabled && sidebar()}
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { BaseLayoutProps } from '@/layouts/shared';
|
||||
import { TreeContextProvider } from '@/contexts/tree';
|
||||
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
|
||||
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
|
||||
import { type ComponentProps, HTMLAttributes, type ReactNode, useMemo } from 'react';
|
||||
import { type ComponentProps, type ReactNode, useMemo } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { SidebarViewport, useSidebar } from '@/components/sidebar/base';
|
||||
import { useSidebar } from '@/components/sidebar/base';
|
||||
import { SidebarTabsDropdown, type SidebarTabWithProps } from './tab-dropdown';
|
||||
import { Sidebar, SidebarContent, SidebarLinkItem, SidebarPageTree } from './sidebar';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Languages, SidebarIcon, XIcon } from 'lucide-react';
|
||||
import { renderTitleNav, useLinkItems } from '../shared';
|
||||
import { parseLayoutProps, renderTitleNav, useLinkItems } from '../shared';
|
||||
import { LanguageToggle } from '../shared/language-toggle';
|
||||
import { SearchToggle } from '../shared/search-toggle';
|
||||
import { ThemeToggle } from '../shared/theme-toggle';
|
||||
@@ -19,18 +19,19 @@ import { LinkItem } from '@/utils/link-item';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { RemoveScroll } from 'react-remove-scroll';
|
||||
import { useSearchContext } from '@/contexts/search';
|
||||
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
|
||||
import { renderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
|
||||
sidebar?: SidebarOptions;
|
||||
Container?: Renderer<ComponentProps<'div'>>;
|
||||
renderNavigationPanel?: (props: NavigationPanelProps) => ReactNode;
|
||||
|
||||
/**
|
||||
* Props for the `div` container
|
||||
* @deprecated use `Container` instead.
|
||||
*/
|
||||
containerProps?: HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
renderNavigationPanel?: (props: NavigationPanelProps) => ReactNode;
|
||||
containerProps?: ComponentProps<'div'>;
|
||||
}
|
||||
|
||||
interface SidebarOptions
|
||||
@@ -50,24 +51,32 @@ interface SidebarOptions
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function DocsLayout({
|
||||
tree,
|
||||
nav = {},
|
||||
sidebar: {
|
||||
enabled: sidebarEnabled = true,
|
||||
tabs: sidebarTabs,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
searchToggle = {},
|
||||
themeSwitch = {},
|
||||
i18n = false,
|
||||
children,
|
||||
containerProps,
|
||||
renderNavigationPanel = (props) => <NavigationPanel {...props} />,
|
||||
...props
|
||||
}: DocsLayoutProps) {
|
||||
export function DocsLayout(_: DocsLayoutProps) {
|
||||
const {
|
||||
tree,
|
||||
nav = {},
|
||||
sidebar: {
|
||||
enabled: sidebarEnabled = true,
|
||||
tabs: sidebarTabs,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
children,
|
||||
containerProps,
|
||||
Container = containerProps ?? true,
|
||||
LanguageSwitch,
|
||||
SearchToggle: SearchToggleRenderer,
|
||||
ThemeSwitch,
|
||||
renderNavigationPanel = (props) => <NavigationPanel {...props} />,
|
||||
...props
|
||||
} = parseLayoutProps<DocsLayoutProps>(_);
|
||||
const { menuItems } = useLinkItems(props);
|
||||
const renderContainer = renderer(Container, 'div');
|
||||
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
|
||||
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
|
||||
const renderSearchToggle = renderer(SearchToggleRenderer, SearchToggle);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(sidebarTabs)) {
|
||||
return sidebarTabs;
|
||||
@@ -80,7 +89,6 @@ export function DocsLayout({
|
||||
}
|
||||
return [];
|
||||
}, [tree, sidebarTabs]);
|
||||
const { menuItems } = useLinkItems(props);
|
||||
const iconLinks = menuItems.filter((item) => item.type === 'icon');
|
||||
|
||||
function sidebar() {
|
||||
@@ -90,18 +98,28 @@ export function DocsLayout({
|
||||
return (
|
||||
<SidebarContent {...rest}>
|
||||
<div className="flex flex-col gap-3 p-4 pb-2 empty:hidden">{banner}</div>
|
||||
<SidebarViewport>
|
||||
{menuItems
|
||||
.filter((v) => v.type !== 'icon')
|
||||
.map((item, i, list) => (
|
||||
<SidebarLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(i === list.length - 1 && 'mb-4')}
|
||||
/>
|
||||
))}
|
||||
<SidebarPageTree {...components} />
|
||||
</SidebarViewport>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<ScrollViewport
|
||||
className="p-4 overscroll-contain"
|
||||
style={
|
||||
{
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, transparent, white 12px, white calc(100% - 12px), transparent)',
|
||||
} as object
|
||||
}
|
||||
>
|
||||
{menuItems
|
||||
.filter((v) => v.type !== 'icon')
|
||||
.map((item, i, list) => (
|
||||
<SidebarLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(i === list.length - 1 && 'mb-4')}
|
||||
/>
|
||||
))}
|
||||
<SidebarPageTree {...components} />
|
||||
</ScrollViewport>
|
||||
</ScrollArea>
|
||||
{footer}
|
||||
</SidebarContent>
|
||||
);
|
||||
@@ -110,17 +128,17 @@ export function DocsLayout({
|
||||
return (
|
||||
<TreeContextProvider tree={tree}>
|
||||
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
|
||||
<div
|
||||
id="nd-flux-layout"
|
||||
{...containerProps}
|
||||
className={cn(
|
||||
'flex flex-col items-center pb-24 overflow-x-clip',
|
||||
containerProps?.className,
|
||||
)}
|
||||
>
|
||||
{sidebarEnabled && sidebar()}
|
||||
{children}
|
||||
</div>
|
||||
{renderContainer?.((t) => ({
|
||||
id: 'nd-flux-layout',
|
||||
children: (
|
||||
<>
|
||||
{sidebarEnabled && sidebar()}
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
...t,
|
||||
className: cn('flex flex-col items-center pb-24 overflow-x-clip', t?.className),
|
||||
}))}
|
||||
{renderNavigationPanel({
|
||||
head: renderTitleNav(nav, {
|
||||
className: 'inline-flex items-center gap-2.5 text-sm font-semibold',
|
||||
@@ -128,25 +146,21 @@ export function DocsLayout({
|
||||
tabDropdown: tabs.length > 0 && <SidebarTabsDropdown className="flex-1" options={tabs} />,
|
||||
tool: (
|
||||
<>
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-4.5" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? (
|
||||
<SearchToggle className="rounded-lg" hideIfDisabled />
|
||||
))}
|
||||
{renderLanguageSwitch?.((t) => ({
|
||||
children: <Languages className="size-4.5" />,
|
||||
...t,
|
||||
}))}
|
||||
{renderSearchToggle?.((t) => ({
|
||||
hideIfDisabled: true,
|
||||
...t,
|
||||
className: cn('rounded-lg', t?.className),
|
||||
}))}
|
||||
|
||||
<NavigationSidebarTrigger />
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle
|
||||
className="px-1 py-0 h-full ms-1 rounded-xl bg-fd-muted *:rounded-lg"
|
||||
mode={themeSwitch.mode}
|
||||
/>
|
||||
))}
|
||||
{renderThemeSwitch?.((t) => ({
|
||||
...t,
|
||||
className: cn('p-1 h-full ms-1 rounded-xl bg-fd-muted *:rounded-lg', t?.className),
|
||||
}))}
|
||||
</>
|
||||
),
|
||||
link: iconLinks.map((item, i) => (
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
import { cva } from 'class-variance-authority';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type LinkItemType, type NavOptions, renderTitleNav, useLinkItems } from '@/layouts/shared';
|
||||
import {
|
||||
type LinkItemType,
|
||||
type NavOptions,
|
||||
parseLayoutProps,
|
||||
renderTitleNav,
|
||||
useLinkItems,
|
||||
} from '@/layouts/shared';
|
||||
import { LinkItem } from '@/utils/link-item';
|
||||
import {
|
||||
NavigationMenuRoot,
|
||||
@@ -31,6 +37,7 @@ import { ChevronDown, Languages } from 'lucide-react';
|
||||
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
|
||||
import { NavigationMenu } from '@base-ui/react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { renderer } from '@/utils/renderer';
|
||||
|
||||
const MobileMenuContext = createContext<{
|
||||
open: boolean;
|
||||
@@ -56,14 +63,21 @@ export const navItemVariants = cva('[&_svg]:size-4', {
|
||||
},
|
||||
});
|
||||
|
||||
export function Header({
|
||||
nav = {},
|
||||
i18n = false,
|
||||
links,
|
||||
githubUrl,
|
||||
themeSwitch = {},
|
||||
searchToggle = {},
|
||||
}: HomeLayoutProps) {
|
||||
export function Header(props: HomeLayoutProps) {
|
||||
const {
|
||||
nav = {},
|
||||
LanguageSwitch,
|
||||
links,
|
||||
githubUrl,
|
||||
SearchToggle: SearchToggleRenderer,
|
||||
LargeSearchToggle: LargeSearchToggleRenderer,
|
||||
ThemeSwitch,
|
||||
} = parseLayoutProps(props);
|
||||
|
||||
const renderSearchToggle = renderer(SearchToggleRenderer, SearchToggle);
|
||||
const renderLargeSearchToggle = renderer(LargeSearchToggleRenderer, LargeSearchToggle);
|
||||
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
|
||||
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
|
||||
const { menuItems, navItems } = useLinkItems({ links, githubUrl });
|
||||
|
||||
return (
|
||||
@@ -86,20 +100,16 @@ export function Header({
|
||||
))}
|
||||
</NavigationMenuList>
|
||||
<div className="flex flex-row items-center justify-end gap-1.5 flex-1 max-lg:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ?? (
|
||||
<LargeSearchToggle
|
||||
className="w-full rounded-full ps-2.5 max-w-[240px]"
|
||||
hideIfDisabled
|
||||
/>
|
||||
))}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? <ThemeToggle mode={themeSwitch?.mode} />)}
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-5" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{renderLargeSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn('w-full rounded-full ps-2.5 max-w-[240px]', o?.className),
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => t ?? {})}
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: <Languages className="size-5" />,
|
||||
...o,
|
||||
}))}
|
||||
<NavigationMenuList className="flex flex-row gap-2 items-center empty:hidden">
|
||||
{navItems.filter(isSecondary).map((item, i) => (
|
||||
<NavigationMenuLinkItem
|
||||
@@ -111,8 +121,11 @@ export function Header({
|
||||
</NavigationMenuList>
|
||||
</div>
|
||||
<div className="flex flex-row items-center ms-auto -me-1.5 lg:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? <SearchToggle className="p-2" hideIfDisabled />)}
|
||||
{renderSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn('p-2', o?.className),
|
||||
}))}
|
||||
<CollapsibleTrigger
|
||||
aria-label="Toggle Menu"
|
||||
className={cn(
|
||||
@@ -142,15 +155,17 @@ export function Header({
|
||||
/>
|
||||
))}
|
||||
<div role="separator" className="flex-1 sm:hidden" />
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-5" />
|
||||
<LanguageToggleText />
|
||||
<ChevronDown className="size-3 text-fd-muted-foreground" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? <ThemeToggle mode={themeSwitch?.mode} />)}
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: (
|
||||
<>
|
||||
<Languages className="size-5" />
|
||||
<LanguageToggleText />
|
||||
<ChevronDown className="size-3 text-fd-muted-foreground" />
|
||||
</>
|
||||
),
|
||||
...o,
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => t ?? {})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
<NavigationMenu.Portal>
|
||||
|
||||
@@ -2,32 +2,61 @@ import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type BaseLayoutProps, type NavOptions } from '@/layouts/shared';
|
||||
import { Header } from '@/layouts/home/client';
|
||||
import { renderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
export interface HomeLayoutProps extends BaseLayoutProps {
|
||||
nav?: Partial<NavOptions>;
|
||||
Container?: Renderer<ComponentProps<'main'>>;
|
||||
nav?: Partial<
|
||||
NavOptions & {
|
||||
/**
|
||||
* Open mobile menu when hovering the trigger
|
||||
*/
|
||||
enableHoverToOpen?: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export function HomeLayout(props: HomeLayoutProps & ComponentProps<'main'>) {
|
||||
const { nav = {}, links, githubUrl, i18n, themeSwitch = {}, searchToggle, ...rest } = props;
|
||||
export function HomeLayout({
|
||||
nav = {},
|
||||
links,
|
||||
githubUrl,
|
||||
i18n,
|
||||
LanguageSwitch,
|
||||
ThemeSwitch,
|
||||
SearchToggle,
|
||||
LargeSearchToggle,
|
||||
themeSwitch = {},
|
||||
searchToggle,
|
||||
Container,
|
||||
children,
|
||||
...rest
|
||||
}: HomeLayoutProps & ComponentProps<'main'>) {
|
||||
const renderMain = renderer(Container ?? rest, 'main');
|
||||
|
||||
return (
|
||||
<main
|
||||
id="nd-home-layout"
|
||||
{...rest}
|
||||
className={cn('flex flex-1 flex-col [--fd-layout-width:1400px]', rest.className)}
|
||||
>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
<Header
|
||||
links={links}
|
||||
nav={nav}
|
||||
themeSwitch={themeSwitch}
|
||||
searchToggle={searchToggle}
|
||||
i18n={i18n}
|
||||
githubUrl={githubUrl}
|
||||
/>
|
||||
))}
|
||||
{props.children}
|
||||
</main>
|
||||
);
|
||||
return renderMain?.((t) => ({
|
||||
id: 'nd-home-layout',
|
||||
...t,
|
||||
...rest,
|
||||
className: cn('flex flex-1 flex-col [--fd-layout-width:1400px]', t?.className, rest.className),
|
||||
children: (
|
||||
<>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
<Header
|
||||
links={links}
|
||||
nav={nav}
|
||||
themeSwitch={themeSwitch}
|
||||
searchToggle={searchToggle}
|
||||
i18n={i18n}
|
||||
LanguageSwitch={LanguageSwitch}
|
||||
ThemeSwitch={ThemeSwitch}
|
||||
SearchToggle={SearchToggle}
|
||||
LargeSearchToggle={LargeSearchToggle}
|
||||
githubUrl={githubUrl}
|
||||
/>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ import { useSidebar } from '@/components/sidebar/base';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import { isTabActive, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
|
||||
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
|
||||
import { LinkItem, type LinkItemType, type MenuItemType } from '@/utils/link-item';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { isTabActive, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
|
||||
import { renderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
const LayoutContext = createContext<
|
||||
| (LayoutInfo & {
|
||||
@@ -81,40 +82,45 @@ export function LayoutHeader(props: ComponentProps<'header'>) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutBody({ className, style, children, ...props }: ComponentProps<'div'>) {
|
||||
export function LayoutBody({
|
||||
_,
|
||||
children,
|
||||
}: {
|
||||
_: Renderer<ComponentProps<'div'>>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { navMode } = useNotebookLayout();
|
||||
const { collapsed } = useSidebar();
|
||||
const pageCol =
|
||||
'calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-col) - var(--fd-toc-width))';
|
||||
const render = renderer(_, 'div');
|
||||
|
||||
return (
|
||||
<div
|
||||
id="nd-notebook-layout"
|
||||
className={cn(
|
||||
'grid overflow-x-clip min-h-(--fd-docs-height) transition-[grid-template-columns] auto-cols-auto auto-rows-auto [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
return render?.(
|
||||
(t) =>
|
||||
({
|
||||
id: 'nd-notebook-layout',
|
||||
children,
|
||||
...t,
|
||||
className: cn(
|
||||
'grid overflow-x-clip min-h-(--fd-docs-height) transition-[grid-template-columns] auto-cols-auto auto-rows-auto [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
|
||||
t?.className,
|
||||
),
|
||||
style: {
|
||||
gridTemplate:
|
||||
navMode === 'top'
|
||||
? `". header header header ."
|
||||
"sidebar sidebar toc-popover toc-popover ."
|
||||
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`
|
||||
"sidebar sidebar toc-popover toc-popover ."
|
||||
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`
|
||||
: `"sidebar sidebar header header ."
|
||||
"sidebar sidebar toc-popover toc-popover ."
|
||||
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`,
|
||||
"sidebar sidebar toc-popover toc-popover ."
|
||||
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`,
|
||||
'--fd-docs-row-1': 'var(--fd-banner-height, 0px)',
|
||||
'--fd-docs-row-2': 'calc(var(--fd-docs-row-1) + var(--fd-header-height))',
|
||||
'--fd-docs-row-3': 'calc(var(--fd-docs-row-2) + var(--fd-toc-popover-height))',
|
||||
'--fd-sidebar-col': collapsed ? '0px' : 'var(--fd-sidebar-width)',
|
||||
...style,
|
||||
} as object
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
...t?.style,
|
||||
},
|
||||
}) as ComponentProps<'div'>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { type ComponentProps, type FC, type HTMLAttributes, type ReactNode, useMemo } from 'react';
|
||||
import { type BaseLayoutProps, renderTitleNav, useLinkItems } from '@/layouts/shared';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createElement,
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
parseLayoutProps,
|
||||
renderTitleNav,
|
||||
useLinkItems,
|
||||
} from '@/layouts/shared';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarCollapseTrigger,
|
||||
@@ -25,10 +37,11 @@ import {
|
||||
NavbarLinkItem,
|
||||
} from '@/layouts/notebook/client';
|
||||
import { LargeSearchToggle, SearchToggle } from '@/layouts/shared/search-toggle';
|
||||
import { LinkItem, type LinkItemType } from '@/utils/link-item';
|
||||
import { LinkItem } from '@/utils/link-item';
|
||||
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
|
||||
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
|
||||
import { SidebarTabsDropdown, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
|
||||
import { type Renderer, renderer } from '@/utils/renderer';
|
||||
|
||||
export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
@@ -40,6 +53,12 @@ export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
|
||||
sidebar?: SidebarOptions;
|
||||
|
||||
SidebarTrigger?: Renderer<ComponentProps<'button'>>;
|
||||
Container?: Renderer<ComponentProps<'div'>>;
|
||||
|
||||
/**
|
||||
* @deprecated use `Container` instead.
|
||||
*/
|
||||
containerProps?: HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@@ -70,13 +89,23 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
tabMode = 'sidebar',
|
||||
nav = {},
|
||||
sidebar: { tabs: tabOptions, defaultOpenLevel, prefetch, ...sidebarProps } = {},
|
||||
i18n = false,
|
||||
themeSwitch = {},
|
||||
LanguageSwitch,
|
||||
SearchToggle: searchToggleRenderer,
|
||||
LargeSearchToggle: largeSearchToggleRenderer,
|
||||
ThemeSwitch,
|
||||
SidebarTrigger: SidebarTriggerRenderer = true,
|
||||
containerProps,
|
||||
Container = containerProps ?? true,
|
||||
tree,
|
||||
} = props;
|
||||
|
||||
} = parseLayoutProps(props);
|
||||
const navMode = nav.mode ?? 'auto';
|
||||
const { menuItems, navItems } = useLinkItems(props);
|
||||
const renderSearchToggle = renderer(searchToggleRenderer, SearchToggle);
|
||||
const renderLargeSearchToggle = renderer(largeSearchToggleRenderer, LargeSearchToggle);
|
||||
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
|
||||
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
|
||||
const renderSidebarTrigger = renderer(SidebarTriggerRenderer, SidebarTrigger);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(tabOptions)) {
|
||||
return tabOptions;
|
||||
@@ -95,33 +124,40 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
|
||||
function sidebar() {
|
||||
const { banner, footer, components, collapsible = true, ...rest } = sidebarProps;
|
||||
|
||||
const iconLinks = menuItems.filter((item) => item.type === 'icon');
|
||||
const Header =
|
||||
typeof banner === 'function'
|
||||
? banner
|
||||
: ({ className, ...props }: ComponentProps<'div'>) => (
|
||||
<div className={cn('flex flex-col gap-3 p-4 pb-2 empty:hidden', className)} {...props}>
|
||||
{props.children}
|
||||
{banner}
|
||||
</div>
|
||||
);
|
||||
const Footer =
|
||||
typeof footer === 'function'
|
||||
? footer
|
||||
: ({ className, ...props }: ComponentProps<'div'>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'hidden flex-row text-fd-muted-foreground items-center border-t p-4 pt-2',
|
||||
iconLinks.length > 0 && 'max-lg:flex',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderHeader(props: ComponentProps<'div'>) {
|
||||
if (typeof banner === 'function') return createElement(banner, props);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex flex-col gap-3 p-4 pb-2 empty:hidden', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
{banner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFooter(props: ComponentProps<'div'>) {
|
||||
if (typeof footer === 'function') return createElement(footer, props);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'hidden flex-row text-fd-muted-foreground items-center border-t p-4 pt-2',
|
||||
iconLinks.length > 0 && 'max-lg:flex',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewport = (
|
||||
<SidebarViewport>
|
||||
{menuItems
|
||||
@@ -141,37 +177,41 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<SidebarContent {...rest}>
|
||||
<Header>
|
||||
{navMode === 'auto' && (
|
||||
<div className="flex justify-between">
|
||||
{renderTitleNav(nav, {
|
||||
className: 'inline-flex items-center gap-2.5 font-medium',
|
||||
})}
|
||||
{collapsible && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'mt-px mb-auto text-fd-muted-foreground',
|
||||
}),
|
||||
{renderHeader({
|
||||
children: (
|
||||
<>
|
||||
{navMode === 'auto' && (
|
||||
<div className="flex justify-between">
|
||||
{renderTitleNav(nav, {
|
||||
className: 'inline-flex items-center gap-2.5 font-medium',
|
||||
})}
|
||||
{collapsible && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'mt-px mb-auto text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabs.length > 0 && (
|
||||
<SidebarTabsDropdown
|
||||
options={tabs}
|
||||
className={cn(tabMode === 'navbar' && 'lg:hidden')}
|
||||
/>
|
||||
)}
|
||||
</Header>
|
||||
{tabs.length > 0 && (
|
||||
<SidebarTabsDropdown
|
||||
options={tabs}
|
||||
className={cn(tabMode === 'navbar' && 'lg:hidden')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
{viewport}
|
||||
<Footer>
|
||||
{iconLinks.map((item, i) => (
|
||||
{renderFooter({
|
||||
children: iconLinks.map((item, i) => (
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
@@ -186,75 +226,205 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
>
|
||||
{item.icon}
|
||||
</LinkItem>
|
||||
))}
|
||||
</Footer>
|
||||
)),
|
||||
})}
|
||||
</SidebarContent>
|
||||
<SidebarDrawer {...rest}>
|
||||
<Header>
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
color: 'ghost',
|
||||
className: 'ms-auto text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</SidebarTrigger>
|
||||
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
|
||||
</Header>
|
||||
{renderHeader({
|
||||
children: (
|
||||
<>
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
color: 'ghost',
|
||||
className: 'ms-auto text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</SidebarTrigger>
|
||||
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
{viewport}
|
||||
<Footer
|
||||
className={cn(
|
||||
{renderFooter({
|
||||
className: cn(
|
||||
'hidden flex-row items-center justify-end',
|
||||
(i18n || themeSwitch.enabled !== false) && 'flex',
|
||||
(renderLanguageSwitch || renderThemeSwitch) && '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>
|
||||
))}
|
||||
{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>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: <Languages className="size-4.5 text-fd-muted-foreground" />,
|
||||
...o,
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => t ?? {})}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
</SidebarDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function header() {
|
||||
const sidebarCollapsible = sidebarProps.collapsible ?? true;
|
||||
const showLayoutTabs = tabMode === 'navbar' && tabs.length > 0;
|
||||
|
||||
return (
|
||||
<LayoutHeader
|
||||
id="nd-subnav"
|
||||
className={cn(
|
||||
'sticky [grid-area:header] flex flex-col top-(--fd-docs-row-1) z-10 backdrop-blur-sm transition-colors data-[transparent=false]:bg-fd-background/80 layout:[--fd-header-height:--spacing(14)]',
|
||||
showLayoutTabs && 'lg:layout:[--fd-header-height:--spacing(24)]',
|
||||
)}
|
||||
>
|
||||
<div data-header-body="" className="flex border-b px-4 gap-2 h-14 md:px-6">
|
||||
<div
|
||||
className={cn(
|
||||
'items-center',
|
||||
navMode === 'top' && 'flex flex-1',
|
||||
navMode === 'auto' && 'hidden has-data-[collapsed=true]:md:flex max-md:flex',
|
||||
)}
|
||||
>
|
||||
{sidebarCollapsible && navMode === 'auto' && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
{renderTitleNav(nav, {
|
||||
className: cn(
|
||||
'inline-flex items-center gap-2.5 font-semibold',
|
||||
navMode === 'auto' && 'md:hidden',
|
||||
),
|
||||
})}
|
||||
{nav.children}
|
||||
</div>
|
||||
{renderLargeSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn(
|
||||
'w-full my-auto max-md:hidden',
|
||||
navMode === 'top' ? 'ps-2.5 rounded-xl max-w-sm' : 'max-w-[240px]',
|
||||
o?.className,
|
||||
),
|
||||
}))}
|
||||
<div className="flex flex-1 items-center justify-end md:gap-2">
|
||||
<div className="flex items-center gap-6 empty:hidden max-lg:hidden">
|
||||
{navItems
|
||||
.filter((item) => item.type !== 'icon')
|
||||
.map((item, i) => (
|
||||
<NavbarLinkItem key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{navItems
|
||||
.filter((item) => item.type === 'icon')
|
||||
.map((item, i) => (
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
|
||||
'text-fd-muted-foreground max-lg:hidden',
|
||||
)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</LinkItem>
|
||||
))}
|
||||
|
||||
<div className="flex items-center md:hidden">
|
||||
{renderSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn('p-2', o?.className),
|
||||
}))}
|
||||
{renderSidebarTrigger?.((t) => ({
|
||||
children: <SidebarIcon />,
|
||||
...t,
|
||||
className: cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'p-2 -me-1.5',
|
||||
t?.className,
|
||||
),
|
||||
}))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 max-md:hidden">
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: <Languages className="size-4.5 text-fd-muted-foreground" />,
|
||||
...o,
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => t ?? {})}
|
||||
{sidebarCollapsible && navMode === 'top' && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'text-fd-muted-foreground rounded-full -me-1.5',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showLayoutTabs && (
|
||||
<LayoutHeaderTabs
|
||||
data-header-tabs=""
|
||||
className="overflow-x-auto border-b px-6 h-10 max-lg:hidden"
|
||||
options={tabs}
|
||||
/>
|
||||
)}
|
||||
</LayoutHeader>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeContextProvider tree={tree}>
|
||||
<LayoutContextProvider
|
||||
navMode={nav.mode ?? 'auto'}
|
||||
navMode={navMode}
|
||||
tabMode={tabMode}
|
||||
navTransparentMode={nav.transparentMode}
|
||||
>
|
||||
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
|
||||
<LayoutBody {...props.containerProps}>
|
||||
<LayoutBody _={Container}>
|
||||
{sidebar()}
|
||||
<DocsNavbar {...props} links={navItems} tabs={tabs} />
|
||||
{header()}
|
||||
{props.children}
|
||||
</LayoutBody>
|
||||
</Sidebar>
|
||||
@@ -262,152 +432,3 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
</TreeContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsNavbar({
|
||||
links,
|
||||
tabs,
|
||||
tabMode = 'sidebar',
|
||||
sidebar: { collapsible: sidebarCollapsible = true } = {},
|
||||
searchToggle = {},
|
||||
themeSwitch = {},
|
||||
nav = {},
|
||||
i18n,
|
||||
}: DocsLayoutProps & {
|
||||
links: LinkItemType[];
|
||||
tabs: SidebarTabWithProps[];
|
||||
}) {
|
||||
const navMode = nav.mode ?? 'auto';
|
||||
const showLayoutTabs = tabMode === 'navbar' && tabs.length > 0;
|
||||
|
||||
return (
|
||||
<LayoutHeader
|
||||
id="nd-subnav"
|
||||
className={cn(
|
||||
'sticky [grid-area:header] flex flex-col top-(--fd-docs-row-1) z-10 backdrop-blur-sm transition-colors data-[transparent=false]:bg-fd-background/80 layout:[--fd-header-height:--spacing(14)]',
|
||||
showLayoutTabs && 'lg:layout:[--fd-header-height:--spacing(24)]',
|
||||
)}
|
||||
>
|
||||
<div data-header-body="" className="flex border-b px-4 gap-2 h-14 md:px-6">
|
||||
<div
|
||||
className={cn(
|
||||
'items-center',
|
||||
navMode === 'top' && 'flex flex-1',
|
||||
navMode === 'auto' && 'hidden has-data-[collapsed=true]:md:flex max-md:flex',
|
||||
)}
|
||||
>
|
||||
{sidebarCollapsible && navMode === 'auto' && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
{renderTitleNav(nav, {
|
||||
className: cn(
|
||||
'inline-flex items-center gap-2.5 font-semibold',
|
||||
navMode === 'auto' && 'md:hidden',
|
||||
),
|
||||
})}
|
||||
{nav.children}
|
||||
</div>
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ? (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full my-auto max-md:hidden',
|
||||
navMode === 'top' ? 'rounded-xl max-w-sm' : 'max-w-[240px]',
|
||||
)}
|
||||
>
|
||||
{searchToggle.components.lg}
|
||||
</div>
|
||||
) : (
|
||||
<LargeSearchToggle
|
||||
hideIfDisabled
|
||||
className={cn(
|
||||
'w-full my-auto max-md:hidden',
|
||||
navMode === 'top' ? 'rounded-xl max-w-sm ps-2.5' : 'max-w-[240px]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-1 items-center justify-end md:gap-2">
|
||||
<div className="flex items-center gap-6 empty:hidden max-lg:hidden">
|
||||
{links
|
||||
.filter((item) => item.type !== 'icon')
|
||||
.map((item, i) => (
|
||||
<NavbarLinkItem key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{links
|
||||
.filter((item) => item.type === 'icon')
|
||||
.map((item, i) => (
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
|
||||
'text-fd-muted-foreground max-lg:hidden',
|
||||
)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</LinkItem>
|
||||
))}
|
||||
|
||||
<div className="flex items-center md:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? <SearchToggle hideIfDisabled className="p-2" />)}
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'p-2 -me-1.5',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarTrigger>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 max-md:hidden">
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-4.5 text-fd-muted-foreground" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle mode={themeSwitch.mode ?? 'light-dark-system'} />
|
||||
))}
|
||||
{sidebarCollapsible && navMode === 'top' && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'text-fd-muted-foreground rounded-full -me-1.5',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showLayoutTabs && (
|
||||
<LayoutHeaderTabs
|
||||
data-header-tabs=""
|
||||
className="overflow-x-auto border-b px-6 h-10 max-lg:hidden"
|
||||
options={tabs}
|
||||
/>
|
||||
)}
|
||||
</LayoutHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, type ComponentProps, type ReactNode } from 'react';
|
||||
import { type HTMLAttributes, useMemo, type ComponentProps, type ReactNode } from 'react';
|
||||
import type { I18nConfig } from 'fumadocs-core/i18n';
|
||||
import type { LinkItemType } from '@/utils/link-item';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import type { LargeSearchToggleProps, SearchToggleProps } from './search-toggle';
|
||||
import type { ThemeToggleProps } from './theme-toggle';
|
||||
import { ChildrenRenderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
export interface NavOptions {
|
||||
enabled: boolean;
|
||||
@@ -26,27 +29,10 @@ export interface NavOptions {
|
||||
}
|
||||
|
||||
export interface BaseLayoutProps {
|
||||
themeSwitch?: {
|
||||
enabled?: boolean;
|
||||
component?: ReactNode;
|
||||
mode?: 'light-dark' | 'light-dark-system';
|
||||
};
|
||||
|
||||
searchToggle?: Partial<{
|
||||
enabled: boolean;
|
||||
components: Partial<{
|
||||
sm: ReactNode;
|
||||
lg: ReactNode;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* I18n options
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
i18n?: boolean | I18nConfig;
|
||||
|
||||
ThemeSwitch?: Renderer<ThemeToggleProps>;
|
||||
SearchToggle?: Renderer<SearchToggleProps>;
|
||||
LargeSearchToggle?: Renderer<LargeSearchToggleProps>;
|
||||
LanguageSwitch?: Renderer<HTMLAttributes<HTMLElement>>;
|
||||
/**
|
||||
* GitHub url
|
||||
*/
|
||||
@@ -59,6 +45,55 @@ export interface BaseLayoutProps {
|
||||
nav?: Partial<NavOptions>;
|
||||
|
||||
children?: ReactNode;
|
||||
|
||||
/**
|
||||
* @deprecated use `LanguageSwitch` instead, this is now optional for i18n setups.
|
||||
* @defaultValue false
|
||||
*/
|
||||
i18n?: boolean | I18nConfig;
|
||||
/** @deprecated use `ThemeSwitch` instead */
|
||||
themeSwitch?: {
|
||||
enabled?: boolean;
|
||||
component?: ReactNode;
|
||||
mode?: 'light-dark' | 'light-dark-system';
|
||||
};
|
||||
/** @deprecated use `SearchToggle` & `LargeSearchToggle` instead */
|
||||
searchToggle?: {
|
||||
enabled?: boolean;
|
||||
components?: {
|
||||
sm?: ReactNode;
|
||||
lg?: ReactNode;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLayoutProps<T extends BaseLayoutProps>({
|
||||
i18n = false,
|
||||
themeSwitch = {},
|
||||
searchToggle = {},
|
||||
...rest
|
||||
}: T) {
|
||||
rest.LanguageSwitch ??= !!i18n;
|
||||
|
||||
if (themeSwitch.enabled === false) {
|
||||
rest.ThemeSwitch ??= false;
|
||||
} else if (themeSwitch.component) {
|
||||
rest.ThemeSwitch ??= new ChildrenRenderer(themeSwitch.component);
|
||||
} else {
|
||||
rest.ThemeSwitch ??= { mode: themeSwitch.mode };
|
||||
}
|
||||
|
||||
if (searchToggle.enabled === false) {
|
||||
rest.SearchToggle ??= false;
|
||||
rest.LargeSearchToggle ??= false;
|
||||
} else {
|
||||
const { sm, lg } = searchToggle.components ?? {};
|
||||
rest.SearchToggle ??= sm ? new ChildrenRenderer(sm) : true;
|
||||
rest.LargeSearchToggle ??= lg ? new ChildrenRenderer(lg) : true;
|
||||
}
|
||||
|
||||
return rest as Omit<T, 'themeSwitch' | 'i18n' | 'searchToggle'> &
|
||||
Pick<Required<T>, 'LanguageSwitch' | 'SearchToggle' | 'LargeSearchToggle' | 'ThemeSwitch'>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +141,7 @@ export function useLinkItems({ githubUrl, links }: Pick<BaseLayoutProps, 'links'
|
||||
const menuItems: LinkItemType[] = [];
|
||||
|
||||
for (const item of all) {
|
||||
switch (item.on ?? 'all') {
|
||||
switch (item.on) {
|
||||
case 'menu':
|
||||
menuItems.push(item);
|
||||
break;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useI18n } from '@/contexts/i18n';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type ButtonProps, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
interface SearchToggleProps extends Omit<ComponentProps<'button'>, 'color'>, ButtonProps {
|
||||
export interface SearchToggleProps extends Omit<ComponentProps<'button'>, 'color'>, ButtonProps {
|
||||
hideIfDisabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -40,12 +40,11 @@ export function SearchToggle({
|
||||
);
|
||||
}
|
||||
|
||||
export function LargeSearchToggle({
|
||||
hideIfDisabled,
|
||||
...props
|
||||
}: ComponentProps<'button'> & {
|
||||
export interface LargeSearchToggleProps extends ComponentProps<'button'> {
|
||||
hideIfDisabled?: boolean;
|
||||
}) {
|
||||
}
|
||||
|
||||
export function LargeSearchToggle({ hideIfDisabled, ...props }: LargeSearchToggleProps) {
|
||||
const { enabled, hotKey, setOpenSearch } = useSearchContext();
|
||||
const { text } = useI18n();
|
||||
if (hideIfDisabled && !enabled) return null;
|
||||
@@ -56,7 +55,7 @@ export function LargeSearchToggle({
|
||||
data-search-full=""
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-lg border bg-fd-secondary/50 p-1.5 ps-2 text-sm text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
|
||||
'inline-flex items-center gap-2 rounded-lg border bg-fd-secondary/50 p-1.5 ps-2 text-sm text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground',
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => {
|
||||
|
||||
@@ -16,13 +16,11 @@ const itemVariants = cva('size-6.5 p-1.5 text-fd-muted-foreground', {
|
||||
|
||||
const full = [['light', Sun] as const, ['dark', Moon] as const, ['system', Airplay] as const];
|
||||
|
||||
export function ThemeToggle({
|
||||
className,
|
||||
mode = 'light-dark',
|
||||
...props
|
||||
}: ComponentProps<'div'> & {
|
||||
export interface ThemeToggleProps extends ComponentProps<'div'> {
|
||||
mode?: 'light-dark' | 'light-dark-system';
|
||||
}) {
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className, mode = 'light-dark', ...props }: ThemeToggleProps) {
|
||||
const { setTheme, theme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createElement, type ElementType, type FC, type ReactNode } from 'react';
|
||||
|
||||
export type Renderer<Props> = FC<Props> | Partial<Props> | ChildrenRenderer | boolean;
|
||||
|
||||
export type RenderFn<Props> = (props: (override?: Partial<Props>) => Props) => ReactNode;
|
||||
|
||||
export function renderer<Props extends object>(
|
||||
T: Renderer<Props>,
|
||||
Default: FC<Props> | ElementType<Props>,
|
||||
): RenderFn<Props> | undefined {
|
||||
if (T === false) return;
|
||||
if (T === true)
|
||||
return function Wrapper(props) {
|
||||
return createElement(Default, props());
|
||||
};
|
||||
if (T instanceof ChildrenRenderer)
|
||||
return function Wrapper() {
|
||||
return T.children;
|
||||
};
|
||||
if (typeof T === 'object')
|
||||
return function Wrapper(props) {
|
||||
return createElement(Default, props(T));
|
||||
};
|
||||
return function Wrapper(props) {
|
||||
return createElement(T, props());
|
||||
};
|
||||
}
|
||||
|
||||
export class ChildrenRenderer {
|
||||
constructor(readonly children: ReactNode) {}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import Link from 'fumadocs-core/link';
|
||||
import type { SidebarTab } from '@/components/sidebar/tabs';
|
||||
import { isTabActive } from '@/components/sidebar/tabs/dropdown';
|
||||
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
|
||||
import { renderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
export const LayoutContext = createContext<{
|
||||
isNavTransparent: boolean;
|
||||
@@ -56,33 +57,38 @@ export function LayoutHeader(props: ComponentProps<'header'>) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutBody({ className, style, children, ...props }: ComponentProps<'div'>) {
|
||||
export function LayoutBody({
|
||||
_,
|
||||
children,
|
||||
}: {
|
||||
_: Renderer<ComponentProps<'div'>>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const render = renderer(_, 'div');
|
||||
const { collapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<div
|
||||
id="nd-docs-layout"
|
||||
className={cn(
|
||||
'grid transition-[grid-template-columns] overflow-x-clip min-h-(--fd-docs-height) [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
|
||||
className,
|
||||
)}
|
||||
data-sidebar-collapsed={collapsed}
|
||||
style={
|
||||
{
|
||||
return render?.(
|
||||
(t) =>
|
||||
({
|
||||
id: 'nd-docs-layout',
|
||||
'data-sidebar-collapsed': collapsed,
|
||||
children,
|
||||
...t,
|
||||
className: cn(
|
||||
'grid transition-[grid-template-columns] overflow-x-clip min-h-(--fd-docs-height) [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
|
||||
t?.className,
|
||||
),
|
||||
style: {
|
||||
gridTemplate: `"sidebar sidebar header toc toc"
|
||||
"sidebar sidebar toc-popover toc toc"
|
||||
"sidebar sidebar main toc toc" 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-width) - var(--fd-toc-width))) var(--fd-toc-width) minmax(min-content, 1fr)`,
|
||||
"sidebar sidebar toc-popover toc toc"
|
||||
"sidebar sidebar main toc toc" 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-width) - var(--fd-toc-width))) var(--fd-toc-width) minmax(min-content, 1fr)`,
|
||||
'--fd-docs-row-1': 'var(--fd-banner-height, 0px)',
|
||||
'--fd-docs-row-2': 'calc(var(--fd-docs-row-1) + var(--fd-header-height))',
|
||||
'--fd-docs-row-3': 'calc(var(--fd-docs-row-2) + var(--fd-toc-popover-height))',
|
||||
'--fd-sidebar-col': collapsed ? '0px' : 'var(--fd-sidebar-width)',
|
||||
...style,
|
||||
} as object
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
...t?.style,
|
||||
},
|
||||
}) as ComponentProps<'div'>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
SidebarTrigger,
|
||||
SidebarViewport,
|
||||
} from './sidebar';
|
||||
import { type BaseLayoutProps, renderTitleNav, useLinkItems } from '@/layouts/shared';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
parseLayoutProps,
|
||||
renderTitleNav,
|
||||
useLinkItems,
|
||||
} from '@/layouts/shared';
|
||||
import { LinkItem } from '@/utils/link-item';
|
||||
import { LanguageToggle, LanguageToggleText } from '@/layouts/shared/language-toggle';
|
||||
import { LayoutBody, LayoutContextProvider, LayoutHeader, LayoutTabs } from './client';
|
||||
@@ -23,16 +28,18 @@ import { LargeSearchToggle, SearchToggle } from '@/layouts/shared/search-toggle'
|
||||
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
|
||||
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
|
||||
import { SidebarTabsDropdown, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
|
||||
import { type Renderer, renderer } from '@/utils/renderer';
|
||||
|
||||
export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
|
||||
sidebar?: SidebarOptions;
|
||||
|
||||
tabMode?: 'top' | 'auto';
|
||||
SidebarTrigger?: Renderer<ComponentProps<'button'>>;
|
||||
Container?: Renderer<ComponentProps<'div'>>;
|
||||
|
||||
/**
|
||||
* Props for the `div` container
|
||||
* @deprecated use `Container` instead.
|
||||
*/
|
||||
containerProps?: HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
@@ -61,23 +68,35 @@ interface SidebarOptions
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
export function DocsLayout({
|
||||
nav: { transparentMode, ...nav } = {},
|
||||
sidebar: {
|
||||
tabs: sidebarTabs,
|
||||
enabled: sidebarEnabled = true,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
searchToggle = {},
|
||||
themeSwitch = {},
|
||||
tabMode = 'auto',
|
||||
i18n = false,
|
||||
children,
|
||||
tree,
|
||||
...props
|
||||
}: DocsLayoutProps) {
|
||||
export function DocsLayout(_: DocsLayoutProps) {
|
||||
const {
|
||||
nav: { transparentMode, ...nav } = {},
|
||||
sidebar: {
|
||||
tabs: sidebarTabs,
|
||||
enabled: sidebarEnabled = true,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
SearchToggle: SearchToggleRenderer,
|
||||
LargeSearchToggle: LargeSearchToggleRenderer,
|
||||
ThemeSwitch,
|
||||
containerProps,
|
||||
Container = containerProps ?? true,
|
||||
tabMode = 'auto',
|
||||
LanguageSwitch,
|
||||
SidebarTrigger: SidebarTriggerRenderer = sidebarEnabled,
|
||||
children,
|
||||
tree,
|
||||
...props
|
||||
} = parseLayoutProps<DocsLayoutProps>(_);
|
||||
|
||||
const renderSearchToggle = renderer(SearchToggleRenderer, SearchToggle);
|
||||
const renderLargeSearchToggle = renderer(LargeSearchToggleRenderer, LargeSearchToggle);
|
||||
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
|
||||
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
|
||||
const renderSidebarTrigger = renderer(SidebarTriggerRenderer, SidebarTrigger);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(sidebarTabs)) {
|
||||
return sidebarTabs;
|
||||
@@ -131,20 +150,21 @@ export function DocsLayout({
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
</div>
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ?? <LargeSearchToggle hideIfDisabled />)}
|
||||
{renderLargeSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
}))}
|
||||
{tabs.length > 0 && tabMode === 'auto' && <SidebarTabsDropdown options={tabs} />}
|
||||
{banner}
|
||||
</div>
|
||||
{viewport}
|
||||
{(i18n || iconLinks.length > 0 || themeSwitch?.enabled !== false || footer) && (
|
||||
{(renderLanguageSwitch || iconLinks.length > 0 || renderThemeSwitch || 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>
|
||||
)}
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: <Languages className="size-4.5" />,
|
||||
...o,
|
||||
}))}
|
||||
{iconLinks.map((item, i) => (
|
||||
<LinkItem
|
||||
key={i}
|
||||
@@ -155,10 +175,7 @@ export function DocsLayout({
|
||||
{item.icon}
|
||||
</LinkItem>
|
||||
))}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle className="ms-auto p-0" mode={themeSwitch.mode} />
|
||||
))}
|
||||
{renderThemeSwitch?.((t) => ({ ...t, className: cn('ms-auto p-0', t?.className) }))}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
@@ -185,25 +202,28 @@ export function DocsLayout({
|
||||
</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(
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: (
|
||||
<>
|
||||
<Languages className="size-4.5" />
|
||||
<LanguageToggleText />
|
||||
</>
|
||||
),
|
||||
...o,
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => ({ ...t, className: cn('p-0', t?.className) }))}
|
||||
{renderSidebarTrigger?.((t) => ({
|
||||
children: <SidebarIcon />,
|
||||
...t,
|
||||
className: cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'p-2',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarTrigger>
|
||||
'p-2',
|
||||
t?.className,
|
||||
),
|
||||
}))}
|
||||
</div>
|
||||
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
|
||||
{banner}
|
||||
@@ -219,7 +239,7 @@ export function DocsLayout({
|
||||
<TreeContextProvider tree={tree}>
|
||||
<LayoutContextProvider navTransparentMode={transparentMode}>
|
||||
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
|
||||
<LayoutBody {...props.containerProps}>
|
||||
<LayoutBody _={Container}>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
<LayoutHeader
|
||||
@@ -230,23 +250,23 @@ export function DocsLayout({
|
||||
className: 'inline-flex items-center gap-2.5 font-semibold',
|
||||
})}
|
||||
<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>
|
||||
)}
|
||||
{renderSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn('p-2', o?.className),
|
||||
}))}
|
||||
{renderSidebarTrigger?.((t) => ({
|
||||
children: <SidebarIcon />,
|
||||
...t,
|
||||
className: cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'p-2',
|
||||
t?.className,
|
||||
),
|
||||
}))}
|
||||
</LayoutHeader>
|
||||
))}
|
||||
{sidebarEnabled && sidebar()}
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { BaseLayoutProps } from '@/layouts/shared';
|
||||
import { TreeContextProvider } from '@/contexts/tree';
|
||||
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
|
||||
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
|
||||
import { type ComponentProps, HTMLAttributes, type ReactNode, useMemo } from 'react';
|
||||
import { type ComponentProps, type ReactNode, useMemo } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useSidebar } from '@/components/sidebar/base';
|
||||
import { SidebarTabsDropdown, type SidebarTabWithProps } from './tab-dropdown';
|
||||
import { Sidebar, SidebarContent, SidebarLinkItem, SidebarPageTree } from './sidebar';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Languages, SidebarIcon, XIcon } from 'lucide-react';
|
||||
import { renderTitleNav, useLinkItems } from '../shared';
|
||||
import { parseLayoutProps, renderTitleNav, useLinkItems } from '../shared';
|
||||
import { LanguageToggle } from '../shared/language-toggle';
|
||||
import { SearchToggle } from '../shared/search-toggle';
|
||||
import { ThemeToggle } from '../shared/theme-toggle';
|
||||
@@ -20,18 +20,18 @@ import { AnimatePresence, motion } from 'motion/react';
|
||||
import { RemoveScroll } from 'react-remove-scroll';
|
||||
import { useSearchContext } from '@/contexts/search';
|
||||
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
|
||||
import { renderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
|
||||
sidebar?: SidebarOptions;
|
||||
Container?: Renderer<ComponentProps<'div'>>;
|
||||
renderNavigationPanel?: (props: NavigationPanelProps) => ReactNode;
|
||||
|
||||
/**
|
||||
* Props for the `div` container
|
||||
* @deprecated use `Container` instead.
|
||||
*/
|
||||
containerProps?: HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
renderNavigationPanel?: (props: NavigationPanelProps) => ReactNode;
|
||||
containerProps?: ComponentProps<'div'>;
|
||||
}
|
||||
|
||||
interface SidebarOptions
|
||||
@@ -51,24 +51,32 @@ interface SidebarOptions
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function DocsLayout({
|
||||
tree,
|
||||
nav = {},
|
||||
sidebar: {
|
||||
enabled: sidebarEnabled = true,
|
||||
tabs: sidebarTabs,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
searchToggle = {},
|
||||
themeSwitch = {},
|
||||
i18n = false,
|
||||
children,
|
||||
containerProps,
|
||||
renderNavigationPanel = (props) => <NavigationPanel {...props} />,
|
||||
...props
|
||||
}: DocsLayoutProps) {
|
||||
export function DocsLayout(_: DocsLayoutProps) {
|
||||
const {
|
||||
tree,
|
||||
nav = {},
|
||||
sidebar: {
|
||||
enabled: sidebarEnabled = true,
|
||||
tabs: sidebarTabs,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
children,
|
||||
containerProps,
|
||||
Container = containerProps ?? true,
|
||||
LanguageSwitch,
|
||||
SearchToggle: SearchToggleRenderer,
|
||||
ThemeSwitch,
|
||||
renderNavigationPanel = (props) => <NavigationPanel {...props} />,
|
||||
...props
|
||||
} = parseLayoutProps<DocsLayoutProps>(_);
|
||||
const { menuItems } = useLinkItems(props);
|
||||
const renderContainer = renderer(Container, 'div');
|
||||
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
|
||||
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
|
||||
const renderSearchToggle = renderer(SearchToggleRenderer, SearchToggle);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(sidebarTabs)) {
|
||||
return sidebarTabs;
|
||||
@@ -81,7 +89,6 @@ export function DocsLayout({
|
||||
}
|
||||
return [];
|
||||
}, [tree, sidebarTabs]);
|
||||
const { menuItems } = useLinkItems(props);
|
||||
const iconLinks = menuItems.filter((item) => item.type === 'icon');
|
||||
|
||||
function sidebar() {
|
||||
@@ -121,17 +128,17 @@ export function DocsLayout({
|
||||
return (
|
||||
<TreeContextProvider tree={tree}>
|
||||
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
|
||||
<div
|
||||
id="nd-flux-layout"
|
||||
{...containerProps}
|
||||
className={cn(
|
||||
'flex flex-col items-center pb-24 overflow-x-clip',
|
||||
containerProps?.className,
|
||||
)}
|
||||
>
|
||||
{sidebarEnabled && sidebar()}
|
||||
{children}
|
||||
</div>
|
||||
{renderContainer?.((t) => ({
|
||||
id: 'nd-flux-layout',
|
||||
children: (
|
||||
<>
|
||||
{sidebarEnabled && sidebar()}
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
...t,
|
||||
className: cn('flex flex-col items-center pb-24 overflow-x-clip', t?.className),
|
||||
}))}
|
||||
{renderNavigationPanel({
|
||||
head: renderTitleNav(nav, {
|
||||
className: 'inline-flex items-center gap-2.5 text-sm font-semibold',
|
||||
@@ -139,25 +146,21 @@ export function DocsLayout({
|
||||
tabDropdown: tabs.length > 0 && <SidebarTabsDropdown className="flex-1" options={tabs} />,
|
||||
tool: (
|
||||
<>
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-4.5" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? (
|
||||
<SearchToggle className="rounded-lg" hideIfDisabled />
|
||||
))}
|
||||
{renderLanguageSwitch?.((t) => ({
|
||||
children: <Languages className="size-4.5" />,
|
||||
...t,
|
||||
}))}
|
||||
{renderSearchToggle?.((t) => ({
|
||||
hideIfDisabled: true,
|
||||
...t,
|
||||
className: cn('rounded-lg', t?.className),
|
||||
}))}
|
||||
|
||||
<NavigationSidebarTrigger />
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle
|
||||
className="p-1 h-full ms-1 rounded-xl bg-fd-muted *:rounded-lg"
|
||||
mode={themeSwitch.mode}
|
||||
/>
|
||||
))}
|
||||
{renderThemeSwitch?.((t) => ({
|
||||
...t,
|
||||
className: cn('p-1 h-full ms-1 rounded-xl bg-fd-muted *:rounded-lg', t?.className),
|
||||
}))}
|
||||
</>
|
||||
),
|
||||
link: iconLinks.map((item, i) => (
|
||||
|
||||
@@ -3,7 +3,13 @@ import { type ComponentProps, Fragment, useState } from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type LinkItemType, type NavOptions, renderTitleNav, useLinkItems } from '@/layouts/shared';
|
||||
import {
|
||||
type LinkItemType,
|
||||
type NavOptions,
|
||||
parseLayoutProps,
|
||||
renderTitleNav,
|
||||
useLinkItems,
|
||||
} from '@/layouts/shared';
|
||||
import { LinkItem } from '@/utils/link-item';
|
||||
import {
|
||||
NavigationMenu,
|
||||
@@ -21,6 +27,7 @@ import { ThemeToggle } from '@/layouts/shared/theme-toggle';
|
||||
import { LanguageToggle, LanguageToggleText } from '@/layouts/shared/language-toggle';
|
||||
import { ChevronDown, Languages } from 'lucide-react';
|
||||
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
|
||||
import { renderer } from '@/utils/renderer';
|
||||
|
||||
export const navItemVariants = cva('[&_svg]:size-4', {
|
||||
variants: {
|
||||
@@ -41,14 +48,21 @@ export const navItemVariants = cva('[&_svg]:size-4', {
|
||||
},
|
||||
});
|
||||
|
||||
export function Header({
|
||||
nav = {},
|
||||
i18n = false,
|
||||
links,
|
||||
githubUrl,
|
||||
themeSwitch = {},
|
||||
searchToggle = {},
|
||||
}: HomeLayoutProps) {
|
||||
export function Header(props: HomeLayoutProps) {
|
||||
const {
|
||||
nav = {},
|
||||
LanguageSwitch,
|
||||
links,
|
||||
githubUrl,
|
||||
SearchToggle: SearchToggleRenderer,
|
||||
LargeSearchToggle: LargeSearchToggleRenderer,
|
||||
ThemeSwitch,
|
||||
} = parseLayoutProps(props);
|
||||
|
||||
const renderSearchToggle = renderer(SearchToggleRenderer, SearchToggle);
|
||||
const renderLargeSearchToggle = renderer(LargeSearchToggleRenderer, LargeSearchToggle);
|
||||
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
|
||||
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
|
||||
const { navItems, menuItems } = useLinkItems({ links, githubUrl });
|
||||
|
||||
return (
|
||||
@@ -65,20 +79,16 @@ export function Header({
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-row items-center justify-end gap-1.5 flex-1 max-lg:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ?? (
|
||||
<LargeSearchToggle
|
||||
className="w-full rounded-full ps-2.5 max-w-[240px]"
|
||||
hideIfDisabled
|
||||
/>
|
||||
))}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? <ThemeToggle mode={themeSwitch?.mode} />)}
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-5" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{renderLargeSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn('w-full rounded-full ps-2.5 max-w-[240px]', o?.className),
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => t ?? {})}
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: <Languages className="size-5" />,
|
||||
...o,
|
||||
}))}
|
||||
<ul className="flex flex-row gap-2 items-center empty:hidden">
|
||||
{navItems.filter(isSecondary).map((item, i) => (
|
||||
<NavigationMenuLinkItem
|
||||
@@ -90,8 +100,11 @@ export function Header({
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-row items-center ms-auto -me-1.5 lg:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? <SearchToggle className="p-2" hideIfDisabled />)}
|
||||
{renderSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn('p-2', o?.className),
|
||||
}))}
|
||||
<NavigationMenuItem asChild>
|
||||
<div>
|
||||
<NavigationMenuTrigger
|
||||
@@ -122,15 +135,17 @@ export function Header({
|
||||
/>
|
||||
))}
|
||||
<div role="separator" className="flex-1" />
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-5" />
|
||||
<LanguageToggleText />
|
||||
<ChevronDown className="size-3 text-fd-muted-foreground" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? <ThemeToggle mode={themeSwitch?.mode} />)}
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: (
|
||||
<>
|
||||
<Languages className="size-5" />
|
||||
<LanguageToggleText />
|
||||
<ChevronDown className="size-3 text-fd-muted-foreground" />
|
||||
</>
|
||||
),
|
||||
...o,
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => t ?? {})}
|
||||
</div>
|
||||
</NavigationMenuContent>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type BaseLayoutProps, type NavOptions } from '@/layouts/shared';
|
||||
import { Header } from '@/layouts/home/client';
|
||||
import { renderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
export interface HomeLayoutProps extends BaseLayoutProps {
|
||||
Container?: Renderer<ComponentProps<'main'>>;
|
||||
nav?: Partial<
|
||||
NavOptions & {
|
||||
/**
|
||||
@@ -14,27 +16,47 @@ export interface HomeLayoutProps extends BaseLayoutProps {
|
||||
>;
|
||||
}
|
||||
|
||||
export function HomeLayout(props: HomeLayoutProps & ComponentProps<'main'>) {
|
||||
const { nav = {}, links, githubUrl, i18n, themeSwitch = {}, searchToggle, ...rest } = props;
|
||||
export function HomeLayout({
|
||||
nav = {},
|
||||
links,
|
||||
githubUrl,
|
||||
i18n,
|
||||
LanguageSwitch,
|
||||
ThemeSwitch,
|
||||
SearchToggle,
|
||||
LargeSearchToggle,
|
||||
themeSwitch = {},
|
||||
searchToggle,
|
||||
Container,
|
||||
children,
|
||||
...rest
|
||||
}: HomeLayoutProps & ComponentProps<'main'>) {
|
||||
const renderMain = renderer(Container ?? rest, 'main');
|
||||
|
||||
return (
|
||||
<main
|
||||
id="nd-home-layout"
|
||||
{...rest}
|
||||
className={cn('flex flex-1 flex-col [--fd-layout-width:1400px]', rest.className)}
|
||||
>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
<Header
|
||||
links={links}
|
||||
nav={nav}
|
||||
themeSwitch={themeSwitch}
|
||||
searchToggle={searchToggle}
|
||||
i18n={i18n}
|
||||
githubUrl={githubUrl}
|
||||
/>
|
||||
))}
|
||||
{props.children}
|
||||
</main>
|
||||
);
|
||||
return renderMain?.((t) => ({
|
||||
id: 'nd-home-layout',
|
||||
...t,
|
||||
...rest,
|
||||
className: cn('flex flex-1 flex-col [--fd-layout-width:1400px]', t?.className, rest.className),
|
||||
children: (
|
||||
<>
|
||||
{nav.enabled !== false &&
|
||||
(nav.component ?? (
|
||||
<Header
|
||||
links={links}
|
||||
nav={nav}
|
||||
themeSwitch={themeSwitch}
|
||||
searchToggle={searchToggle}
|
||||
i18n={i18n}
|
||||
LanguageSwitch={LanguageSwitch}
|
||||
ThemeSwitch={ThemeSwitch}
|
||||
SearchToggle={SearchToggle}
|
||||
LargeSearchToggle={LargeSearchToggle}
|
||||
githubUrl={githubUrl}
|
||||
/>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useIsScrollTop } from '@/utils/use-is-scroll-top';
|
||||
import { LinkItem, type LinkItemType, type MenuItemType } from '@/utils/link-item';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { isTabActive, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
|
||||
import { renderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
const LayoutContext = createContext<
|
||||
| (LayoutInfo & {
|
||||
@@ -81,40 +82,45 @@ export function LayoutHeader(props: ComponentProps<'header'>) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutBody({ className, style, children, ...props }: ComponentProps<'div'>) {
|
||||
export function LayoutBody({
|
||||
_,
|
||||
children,
|
||||
}: {
|
||||
_: Renderer<ComponentProps<'div'>>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { navMode } = useNotebookLayout();
|
||||
const { collapsed } = useSidebar();
|
||||
const pageCol =
|
||||
'calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-col) - var(--fd-toc-width))';
|
||||
const render = renderer(_, 'div');
|
||||
|
||||
return (
|
||||
<div
|
||||
id="nd-notebook-layout"
|
||||
className={cn(
|
||||
'grid overflow-x-clip min-h-(--fd-docs-height) transition-[grid-template-columns] auto-cols-auto auto-rows-auto [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
return render?.(
|
||||
(t) =>
|
||||
({
|
||||
id: 'nd-notebook-layout',
|
||||
children,
|
||||
...t,
|
||||
className: cn(
|
||||
'grid overflow-x-clip min-h-(--fd-docs-height) transition-[grid-template-columns] auto-cols-auto auto-rows-auto [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
|
||||
t?.className,
|
||||
),
|
||||
style: {
|
||||
gridTemplate:
|
||||
navMode === 'top'
|
||||
? `". header header header ."
|
||||
"sidebar sidebar toc-popover toc-popover ."
|
||||
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`
|
||||
"sidebar sidebar toc-popover toc-popover ."
|
||||
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`
|
||||
: `"sidebar sidebar header header ."
|
||||
"sidebar sidebar toc-popover toc-popover ."
|
||||
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`,
|
||||
"sidebar sidebar toc-popover toc-popover ."
|
||||
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`,
|
||||
'--fd-docs-row-1': 'var(--fd-banner-height, 0px)',
|
||||
'--fd-docs-row-2': 'calc(var(--fd-docs-row-1) + var(--fd-header-height))',
|
||||
'--fd-docs-row-3': 'calc(var(--fd-docs-row-2) + var(--fd-toc-popover-height))',
|
||||
'--fd-sidebar-col': collapsed ? '0px' : 'var(--fd-sidebar-width)',
|
||||
...style,
|
||||
} as object
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
...t?.style,
|
||||
},
|
||||
}) as ComponentProps<'div'>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { type ComponentProps, type FC, type HTMLAttributes, type ReactNode, useMemo } from 'react';
|
||||
import { type BaseLayoutProps, renderTitleNav, useLinkItems } from '@/layouts/shared';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createElement,
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
type BaseLayoutProps,
|
||||
parseLayoutProps,
|
||||
renderTitleNav,
|
||||
useLinkItems,
|
||||
} from '@/layouts/shared';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarCollapseTrigger,
|
||||
@@ -25,10 +37,11 @@ import {
|
||||
NavbarLinkItem,
|
||||
} from '@/layouts/notebook/client';
|
||||
import { LargeSearchToggle, SearchToggle } from '@/layouts/shared/search-toggle';
|
||||
import { LinkItem, type LinkItemType } from '@/utils/link-item';
|
||||
import { LinkItem } from '@/utils/link-item';
|
||||
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
|
||||
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
|
||||
import { SidebarTabsDropdown, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
|
||||
import { type Renderer, renderer } from '@/utils/renderer';
|
||||
|
||||
export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
@@ -40,6 +53,12 @@ export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
|
||||
sidebar?: SidebarOptions;
|
||||
|
||||
SidebarTrigger?: Renderer<ComponentProps<'button'>>;
|
||||
Container?: Renderer<ComponentProps<'div'>>;
|
||||
|
||||
/**
|
||||
* @deprecated use `Container` instead.
|
||||
*/
|
||||
containerProps?: HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@@ -70,13 +89,23 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
tabMode = 'sidebar',
|
||||
nav = {},
|
||||
sidebar: { tabs: tabOptions, defaultOpenLevel, prefetch, ...sidebarProps } = {},
|
||||
i18n = false,
|
||||
themeSwitch = {},
|
||||
LanguageSwitch,
|
||||
SearchToggle: searchToggleRenderer,
|
||||
LargeSearchToggle: largeSearchToggleRenderer,
|
||||
ThemeSwitch,
|
||||
SidebarTrigger: SidebarTriggerRenderer = true,
|
||||
containerProps,
|
||||
Container = containerProps ?? true,
|
||||
tree,
|
||||
} = props;
|
||||
|
||||
} = parseLayoutProps(props);
|
||||
const navMode = nav.mode ?? 'auto';
|
||||
const { menuItems, navItems } = useLinkItems(props);
|
||||
const renderSearchToggle = renderer(searchToggleRenderer, SearchToggle);
|
||||
const renderLargeSearchToggle = renderer(largeSearchToggleRenderer, LargeSearchToggle);
|
||||
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
|
||||
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
|
||||
const renderSidebarTrigger = renderer(SidebarTriggerRenderer, SidebarTrigger);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
if (Array.isArray(tabOptions)) {
|
||||
return tabOptions;
|
||||
@@ -95,33 +124,40 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
|
||||
function sidebar() {
|
||||
const { banner, footer, components, collapsible = true, ...rest } = sidebarProps;
|
||||
|
||||
const iconLinks = menuItems.filter((item) => item.type === 'icon');
|
||||
const Header =
|
||||
typeof banner === 'function'
|
||||
? banner
|
||||
: ({ className, ...props }: ComponentProps<'div'>) => (
|
||||
<div className={cn('flex flex-col gap-3 p-4 pb-2 empty:hidden', className)} {...props}>
|
||||
{props.children}
|
||||
{banner}
|
||||
</div>
|
||||
);
|
||||
const Footer =
|
||||
typeof footer === 'function'
|
||||
? footer
|
||||
: ({ className, ...props }: ComponentProps<'div'>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'hidden flex-row text-fd-muted-foreground items-center border-t p-4 pt-2',
|
||||
iconLinks.length > 0 && 'max-lg:flex',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderHeader(props: ComponentProps<'div'>) {
|
||||
if (typeof banner === 'function') return createElement(banner, props);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex flex-col gap-3 p-4 pb-2 empty:hidden', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
{banner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFooter(props: ComponentProps<'div'>) {
|
||||
if (typeof footer === 'function') return createElement(footer, props);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'hidden flex-row text-fd-muted-foreground items-center border-t p-4 pt-2',
|
||||
iconLinks.length > 0 && 'max-lg:flex',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewport = (
|
||||
<SidebarViewport>
|
||||
{menuItems
|
||||
@@ -141,37 +177,41 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<SidebarContent {...rest}>
|
||||
<Header>
|
||||
{navMode === 'auto' && (
|
||||
<div className="flex justify-between">
|
||||
{renderTitleNav(nav, {
|
||||
className: 'inline-flex items-center gap-2.5 font-medium',
|
||||
})}
|
||||
{collapsible && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'mt-px mb-auto text-fd-muted-foreground',
|
||||
}),
|
||||
{renderHeader({
|
||||
children: (
|
||||
<>
|
||||
{navMode === 'auto' && (
|
||||
<div className="flex justify-between">
|
||||
{renderTitleNav(nav, {
|
||||
className: 'inline-flex items-center gap-2.5 font-medium',
|
||||
})}
|
||||
{collapsible && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'mt-px mb-auto text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabs.length > 0 && (
|
||||
<SidebarTabsDropdown
|
||||
options={tabs}
|
||||
className={cn(tabMode === 'navbar' && 'lg:hidden')}
|
||||
/>
|
||||
)}
|
||||
</Header>
|
||||
{tabs.length > 0 && (
|
||||
<SidebarTabsDropdown
|
||||
options={tabs}
|
||||
className={cn(tabMode === 'navbar' && 'lg:hidden')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
{viewport}
|
||||
<Footer>
|
||||
{iconLinks.map((item, i) => (
|
||||
{renderFooter({
|
||||
children: iconLinks.map((item, i) => (
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
@@ -186,75 +226,205 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
>
|
||||
{item.icon}
|
||||
</LinkItem>
|
||||
))}
|
||||
</Footer>
|
||||
)),
|
||||
})}
|
||||
</SidebarContent>
|
||||
<SidebarDrawer {...rest}>
|
||||
<Header>
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
color: 'ghost',
|
||||
className: 'ms-auto text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</SidebarTrigger>
|
||||
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
|
||||
</Header>
|
||||
{renderHeader({
|
||||
children: (
|
||||
<>
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
color: 'ghost',
|
||||
className: 'ms-auto text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</SidebarTrigger>
|
||||
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
{viewport}
|
||||
<Footer
|
||||
className={cn(
|
||||
{renderFooter({
|
||||
className: cn(
|
||||
'hidden flex-row items-center justify-end',
|
||||
(i18n || themeSwitch.enabled !== false) && 'flex',
|
||||
(renderLanguageSwitch || renderThemeSwitch) && '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>
|
||||
))}
|
||||
{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>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: <Languages className="size-4.5 text-fd-muted-foreground" />,
|
||||
...o,
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => t ?? {})}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
</SidebarDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function header() {
|
||||
const sidebarCollapsible = sidebarProps.collapsible ?? true;
|
||||
const showLayoutTabs = tabMode === 'navbar' && tabs.length > 0;
|
||||
|
||||
return (
|
||||
<LayoutHeader
|
||||
id="nd-subnav"
|
||||
className={cn(
|
||||
'sticky [grid-area:header] flex flex-col top-(--fd-docs-row-1) z-10 backdrop-blur-sm transition-colors data-[transparent=false]:bg-fd-background/80 layout:[--fd-header-height:--spacing(14)]',
|
||||
showLayoutTabs && 'lg:layout:[--fd-header-height:--spacing(24)]',
|
||||
)}
|
||||
>
|
||||
<div data-header-body="" className="flex border-b px-4 gap-2 h-14 md:px-6">
|
||||
<div
|
||||
className={cn(
|
||||
'items-center',
|
||||
navMode === 'top' && 'flex flex-1',
|
||||
navMode === 'auto' && 'hidden has-data-[collapsed=true]:md:flex max-md:flex',
|
||||
)}
|
||||
>
|
||||
{sidebarCollapsible && navMode === 'auto' && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
{renderTitleNav(nav, {
|
||||
className: cn(
|
||||
'inline-flex items-center gap-2.5 font-semibold',
|
||||
navMode === 'auto' && 'md:hidden',
|
||||
),
|
||||
})}
|
||||
{nav.children}
|
||||
</div>
|
||||
{renderLargeSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn(
|
||||
'w-full my-auto max-md:hidden',
|
||||
navMode === 'top' ? 'ps-2.5 rounded-xl max-w-sm' : 'max-w-[240px]',
|
||||
o?.className,
|
||||
),
|
||||
}))}
|
||||
<div className="flex flex-1 items-center justify-end md:gap-2">
|
||||
<div className="flex items-center gap-6 empty:hidden max-lg:hidden">
|
||||
{navItems
|
||||
.filter((item) => item.type !== 'icon')
|
||||
.map((item, i) => (
|
||||
<NavbarLinkItem key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{navItems
|
||||
.filter((item) => item.type === 'icon')
|
||||
.map((item, i) => (
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
|
||||
'text-fd-muted-foreground max-lg:hidden',
|
||||
)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</LinkItem>
|
||||
))}
|
||||
|
||||
<div className="flex items-center md:hidden">
|
||||
{renderSearchToggle?.((o) => ({
|
||||
hideIfDisabled: true,
|
||||
...o,
|
||||
className: cn('p-2', o?.className),
|
||||
}))}
|
||||
{renderSidebarTrigger?.((t) => ({
|
||||
children: <SidebarIcon />,
|
||||
...t,
|
||||
className: cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'p-2 -me-1.5',
|
||||
t?.className,
|
||||
),
|
||||
}))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 max-md:hidden">
|
||||
{renderLanguageSwitch?.((o) => ({
|
||||
children: <Languages className="size-4.5 text-fd-muted-foreground" />,
|
||||
...o,
|
||||
}))}
|
||||
{renderThemeSwitch?.((t) => t ?? {})}
|
||||
{sidebarCollapsible && navMode === 'top' && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'text-fd-muted-foreground rounded-full -me-1.5',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showLayoutTabs && (
|
||||
<LayoutHeaderTabs
|
||||
data-header-tabs=""
|
||||
className="overflow-x-auto border-b px-6 h-10 max-lg:hidden"
|
||||
options={tabs}
|
||||
/>
|
||||
)}
|
||||
</LayoutHeader>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeContextProvider tree={tree}>
|
||||
<LayoutContextProvider
|
||||
navMode={nav.mode ?? 'auto'}
|
||||
navMode={navMode}
|
||||
tabMode={tabMode}
|
||||
navTransparentMode={nav.transparentMode}
|
||||
>
|
||||
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
|
||||
<LayoutBody {...props.containerProps}>
|
||||
<LayoutBody _={Container}>
|
||||
{sidebar()}
|
||||
<DocsNavbar {...props} links={navItems} tabs={tabs} />
|
||||
{header()}
|
||||
{props.children}
|
||||
</LayoutBody>
|
||||
</Sidebar>
|
||||
@@ -262,152 +432,3 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
</TreeContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsNavbar({
|
||||
links,
|
||||
tabs,
|
||||
tabMode = 'sidebar',
|
||||
sidebar: { collapsible: sidebarCollapsible = true } = {},
|
||||
searchToggle = {},
|
||||
themeSwitch = {},
|
||||
nav = {},
|
||||
i18n,
|
||||
}: DocsLayoutProps & {
|
||||
links: LinkItemType[];
|
||||
tabs: SidebarTabWithProps[];
|
||||
}) {
|
||||
const navMode = nav.mode ?? 'auto';
|
||||
const showLayoutTabs = tabMode === 'navbar' && tabs.length > 0;
|
||||
|
||||
return (
|
||||
<LayoutHeader
|
||||
id="nd-subnav"
|
||||
className={cn(
|
||||
'sticky [grid-area:header] flex flex-col top-(--fd-docs-row-1) z-10 backdrop-blur-sm transition-colors data-[transparent=false]:bg-fd-background/80 layout:[--fd-header-height:--spacing(14)]',
|
||||
showLayoutTabs && 'lg:layout:[--fd-header-height:--spacing(24)]',
|
||||
)}
|
||||
>
|
||||
<div data-header-body="" className="flex border-b px-4 gap-2 h-14 md:px-6">
|
||||
<div
|
||||
className={cn(
|
||||
'items-center',
|
||||
navMode === 'top' && 'flex flex-1',
|
||||
navMode === 'auto' && 'hidden has-data-[collapsed=true]:md:flex max-md:flex',
|
||||
)}
|
||||
>
|
||||
{sidebarCollapsible && navMode === 'auto' && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
{renderTitleNav(nav, {
|
||||
className: cn(
|
||||
'inline-flex items-center gap-2.5 font-semibold',
|
||||
navMode === 'auto' && 'md:hidden',
|
||||
),
|
||||
})}
|
||||
{nav.children}
|
||||
</div>
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ? (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full my-auto max-md:hidden',
|
||||
navMode === 'top' ? 'rounded-xl max-w-sm' : 'max-w-[240px]',
|
||||
)}
|
||||
>
|
||||
{searchToggle.components.lg}
|
||||
</div>
|
||||
) : (
|
||||
<LargeSearchToggle
|
||||
hideIfDisabled
|
||||
className={cn(
|
||||
'w-full my-auto max-md:hidden',
|
||||
navMode === 'top' ? 'rounded-xl max-w-sm ps-2.5' : 'max-w-[240px]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-1 items-center justify-end md:gap-2">
|
||||
<div className="flex items-center gap-6 empty:hidden max-lg:hidden">
|
||||
{links
|
||||
.filter((item) => item.type !== 'icon')
|
||||
.map((item, i) => (
|
||||
<NavbarLinkItem key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{links
|
||||
.filter((item) => item.type === 'icon')
|
||||
.map((item, i) => (
|
||||
<LinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
|
||||
'text-fd-muted-foreground max-lg:hidden',
|
||||
)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</LinkItem>
|
||||
))}
|
||||
|
||||
<div className="flex items-center md:hidden">
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? <SearchToggle hideIfDisabled className="p-2" />)}
|
||||
<SidebarTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'p-2 -me-1.5',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarTrigger>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 max-md:hidden">
|
||||
{i18n && (
|
||||
<LanguageToggle>
|
||||
<Languages className="size-4.5 text-fd-muted-foreground" />
|
||||
</LanguageToggle>
|
||||
)}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle mode={themeSwitch.mode ?? 'light-dark-system'} />
|
||||
))}
|
||||
{sidebarCollapsible && navMode === 'top' && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'secondary',
|
||||
size: 'icon-sm',
|
||||
}),
|
||||
'text-fd-muted-foreground rounded-full -me-1.5',
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showLayoutTabs && (
|
||||
<LayoutHeaderTabs
|
||||
data-header-tabs=""
|
||||
className="overflow-x-auto border-b px-6 h-10 max-lg:hidden"
|
||||
options={tabs}
|
||||
/>
|
||||
)}
|
||||
</LayoutHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, type ComponentProps, type ReactNode } from 'react';
|
||||
import { type HTMLAttributes, useMemo, type ComponentProps, type ReactNode } from 'react';
|
||||
import type { I18nConfig } from 'fumadocs-core/i18n';
|
||||
import type { LinkItemType } from '@/utils/link-item';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import type { LargeSearchToggleProps, SearchToggleProps } from './search-toggle';
|
||||
import type { ThemeToggleProps } from './theme-toggle';
|
||||
import { ChildrenRenderer, type Renderer } from '@/utils/renderer';
|
||||
|
||||
export interface NavOptions {
|
||||
enabled: boolean;
|
||||
@@ -26,27 +29,10 @@ export interface NavOptions {
|
||||
}
|
||||
|
||||
export interface BaseLayoutProps {
|
||||
themeSwitch?: {
|
||||
enabled?: boolean;
|
||||
component?: ReactNode;
|
||||
mode?: 'light-dark' | 'light-dark-system';
|
||||
};
|
||||
|
||||
searchToggle?: Partial<{
|
||||
enabled: boolean;
|
||||
components: Partial<{
|
||||
sm: ReactNode;
|
||||
lg: ReactNode;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* I18n options
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
i18n?: boolean | I18nConfig;
|
||||
|
||||
ThemeSwitch?: Renderer<ThemeToggleProps>;
|
||||
SearchToggle?: Renderer<SearchToggleProps>;
|
||||
LargeSearchToggle?: Renderer<LargeSearchToggleProps>;
|
||||
LanguageSwitch?: Renderer<HTMLAttributes<HTMLElement>>;
|
||||
/**
|
||||
* GitHub url
|
||||
*/
|
||||
@@ -59,6 +45,55 @@ export interface BaseLayoutProps {
|
||||
nav?: Partial<NavOptions>;
|
||||
|
||||
children?: ReactNode;
|
||||
|
||||
/**
|
||||
* @deprecated use `LanguageSwitch` instead, this is now optional for i18n setups.
|
||||
* @defaultValue false
|
||||
*/
|
||||
i18n?: boolean | I18nConfig;
|
||||
/** @deprecated use `ThemeSwitch` instead */
|
||||
themeSwitch?: {
|
||||
enabled?: boolean;
|
||||
component?: ReactNode;
|
||||
mode?: 'light-dark' | 'light-dark-system';
|
||||
};
|
||||
/** @deprecated use `SearchToggle` & `LargeSearchToggle` instead */
|
||||
searchToggle?: {
|
||||
enabled?: boolean;
|
||||
components?: {
|
||||
sm?: ReactNode;
|
||||
lg?: ReactNode;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLayoutProps<T extends BaseLayoutProps>({
|
||||
i18n = false,
|
||||
themeSwitch = {},
|
||||
searchToggle = {},
|
||||
...rest
|
||||
}: T) {
|
||||
rest.LanguageSwitch ??= !!i18n;
|
||||
|
||||
if (themeSwitch.enabled === false) {
|
||||
rest.ThemeSwitch ??= false;
|
||||
} else if (themeSwitch.component) {
|
||||
rest.ThemeSwitch ??= new ChildrenRenderer(themeSwitch.component);
|
||||
} else {
|
||||
rest.ThemeSwitch ??= { mode: themeSwitch.mode };
|
||||
}
|
||||
|
||||
if (searchToggle.enabled === false) {
|
||||
rest.SearchToggle ??= false;
|
||||
rest.LargeSearchToggle ??= false;
|
||||
} else {
|
||||
const { sm, lg } = searchToggle.components ?? {};
|
||||
rest.SearchToggle ??= sm ? new ChildrenRenderer(sm) : true;
|
||||
rest.LargeSearchToggle ??= lg ? new ChildrenRenderer(lg) : true;
|
||||
}
|
||||
|
||||
return rest as Omit<T, 'themeSwitch' | 'i18n' | 'searchToggle'> &
|
||||
Pick<Required<T>, 'LanguageSwitch' | 'SearchToggle' | 'LargeSearchToggle' | 'ThemeSwitch'>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useI18n } from '@/contexts/i18n';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type ButtonProps, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
interface SearchToggleProps extends Omit<ComponentProps<'button'>, 'color'>, ButtonProps {
|
||||
export interface SearchToggleProps extends Omit<ComponentProps<'button'>, 'color'>, ButtonProps {
|
||||
hideIfDisabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -40,12 +40,11 @@ export function SearchToggle({
|
||||
);
|
||||
}
|
||||
|
||||
export function LargeSearchToggle({
|
||||
hideIfDisabled,
|
||||
...props
|
||||
}: ComponentProps<'button'> & {
|
||||
export interface LargeSearchToggleProps extends ComponentProps<'button'> {
|
||||
hideIfDisabled?: boolean;
|
||||
}) {
|
||||
}
|
||||
|
||||
export function LargeSearchToggle({ hideIfDisabled, ...props }: LargeSearchToggleProps) {
|
||||
const { enabled, hotKey, setOpenSearch } = useSearchContext();
|
||||
const { text } = useI18n();
|
||||
if (hideIfDisabled && !enabled) return null;
|
||||
|
||||
@@ -16,13 +16,11 @@ const itemVariants = cva('size-6.5 p-1.5 text-fd-muted-foreground', {
|
||||
|
||||
const full = [['light', Sun] as const, ['dark', Moon] as const, ['system', Airplay] as const];
|
||||
|
||||
export function ThemeToggle({
|
||||
className,
|
||||
mode = 'light-dark',
|
||||
...props
|
||||
}: ComponentProps<'div'> & {
|
||||
export interface ThemeToggleProps extends ComponentProps<'div'> {
|
||||
mode?: 'light-dark' | 'light-dark-system';
|
||||
}) {
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className, mode = 'light-dark', ...props }: ThemeToggleProps) {
|
||||
const { setTheme, theme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createElement, type ElementType, type FC, type ReactNode } from 'react';
|
||||
|
||||
export type Renderer<Props> = FC<Props> | Partial<Props> | ChildrenRenderer | boolean;
|
||||
|
||||
export type RenderFn<Props> = (props: (override?: Partial<Props>) => Props) => ReactNode;
|
||||
|
||||
export function renderer<Props extends object>(
|
||||
T: Renderer<Props>,
|
||||
Default: FC<Props> | ElementType<Props>,
|
||||
): RenderFn<Props> | undefined {
|
||||
if (T === false) return;
|
||||
if (T === true)
|
||||
return function Wrapper(props) {
|
||||
return createElement(Default, props());
|
||||
};
|
||||
if (T instanceof ChildrenRenderer)
|
||||
return function Wrapper() {
|
||||
return T.children;
|
||||
};
|
||||
if (typeof T === 'object')
|
||||
return function Wrapper(props) {
|
||||
return createElement(Default, props(T));
|
||||
};
|
||||
return function Wrapper(props) {
|
||||
return createElement(T, props());
|
||||
};
|
||||
}
|
||||
|
||||
export class ChildrenRenderer {
|
||||
constructor(readonly children: ReactNode) {}
|
||||
}
|
||||
Reference in New Issue
Block a user