UI: Implement renderer API for replacing layout components

This commit is contained in:
Fuma Nama
2026-03-16 21:33:03 +08:00
parent 3d1775749b
commit 8bdee703d4
23 changed files with 1376 additions and 1040 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-ui': minor
'@fumadocs/base-ui': minor
---
Implement renderer API for replacing layout components, deprecate old options
+27 -21
View File
@@ -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'>,
);
}
+85 -65
View File
@@ -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()}
+81 -67
View File
@@ -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) => (
+49 -34
View File
@@ -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>
+52 -23
View File
@@ -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'>,
);
}
+282 -261
View File
@@ -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>
);
}
+58 -23
View File
@@ -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);
+31
View File
@@ -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) {}
}
+26 -20
View File
@@ -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'>,
);
}
+85 -65
View File
@@ -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()}
+57 -54
View File
@@ -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) => (
+49 -34
View File
@@ -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>
+44 -22
View File
@@ -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'>,
);
}
+282 -261
View File
@@ -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>
);
}
+57 -22
View File
@@ -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);
+31
View File
@@ -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) {}
}