UI: clean code

This commit is contained in:
Fuma Nama
2026-03-19 18:24:36 +08:00
parent f0255b77e1
commit 7ff531d109
51 changed files with 1685 additions and 1767 deletions
+11
View File
@@ -19,6 +19,17 @@ export const registry: Registry = {
},
components: [
...commonComponents,
{
name: 'link-item',
unlisted: true,
files: [
{
type: 'components',
path: 'utils/link-item.tsx',
target: '<dir>/layout/link-item.tsx',
},
],
},
{
name: 'layouts/shared',
unlisted: true,
+21 -27
View File
@@ -4,11 +4,10 @@ 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;
@@ -57,38 +56,33 @@ export function LayoutHeader(props: ComponentProps<'header'>) {
);
}
export function LayoutBody({
_,
children,
}: {
_: Renderer<ComponentProps<'div'>>;
children: ReactNode;
}) {
const render = renderer(_, 'div');
export function LayoutBody({ className, style, children, ...props }: ComponentProps<'div'>) {
const { collapsed } = useSidebar();
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: {
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={
{
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)',
...t?.style,
},
}) as ComponentProps<'div'>,
...style,
} as object
}
{...props}
>
{children}
</div>
);
}
+65 -85
View File
@@ -13,12 +13,7 @@ import {
SidebarTrigger,
SidebarViewport,
} from './sidebar';
import {
type BaseLayoutProps,
parseLayoutProps,
renderTitleNav,
useLinkItems,
} from '@/layouts/shared';
import { type BaseLayoutProps, 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';
@@ -28,18 +23,16 @@ 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'>>;
/**
* @deprecated use `Container` instead.
* Props for the `div` container
*/
containerProps?: HTMLAttributes<HTMLDivElement>;
}
@@ -68,35 +61,23 @@ interface SidebarOptions
collapsible?: boolean;
}
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);
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) {
const tabs = useMemo(() => {
if (Array.isArray(sidebarTabs)) {
return sidebarTabs;
@@ -150,21 +131,20 @@ export function DocsLayout(_: DocsLayoutProps) {
</SidebarCollapseTrigger>
)}
</div>
{renderLargeSearchToggle?.((o) => ({
hideIfDisabled: true,
...o,
}))}
{searchToggle.enabled !== false &&
(searchToggle.components?.lg ?? <LargeSearchToggle hideIfDisabled />)}
{tabs.length > 0 && tabMode === 'auto' && <SidebarTabsDropdown options={tabs} />}
{banner}
</div>
{viewport}
{(renderLanguageSwitch || iconLinks.length > 0 || renderThemeSwitch || footer) && (
{(i18n || iconLinks.length > 0 || themeSwitch?.enabled !== false || footer) && (
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">
<div className="flex text-fd-muted-foreground items-center empty:hidden">
{renderLanguageSwitch?.((o) => ({
children: <Languages className="size-4.5" />,
...o,
}))}
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
</LanguageToggle>
)}
{iconLinks.map((item, i) => (
<LinkItem
key={i}
@@ -175,7 +155,10 @@ export function DocsLayout(_: DocsLayoutProps) {
{item.icon}
</LinkItem>
))}
{renderThemeSwitch?.((t) => ({ ...t, className: cn('ms-auto p-0', t?.className) }))}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle className="ms-auto p-0" mode={themeSwitch.mode} />
))}
</div>
{footer}
</div>
@@ -202,28 +185,25 @@ export function DocsLayout(_: DocsLayoutProps) {
</LinkItem>
))}
</div>
{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(
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
<LanguageToggleText />
</LanguageToggle>
)}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? <ThemeToggle className="p-0" mode={themeSwitch.mode} />)}
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
'p-2',
t?.className,
),
}))}
)}
>
<SidebarIcon />
</SidebarTrigger>
</div>
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
{banner}
@@ -239,7 +219,7 @@ export function DocsLayout(_: DocsLayoutProps) {
<TreeContextProvider tree={tree}>
<LayoutContextProvider navTransparentMode={transparentMode}>
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<LayoutBody _={Container}>
<LayoutBody {...props.containerProps}>
{nav.enabled !== false &&
(nav.component ?? (
<LayoutHeader
@@ -250,23 +230,23 @@ export function DocsLayout(_: DocsLayoutProps) {
className: 'inline-flex items-center gap-2.5 font-semibold',
})}
<div className="flex-1">{nav.children}</div>
{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,
),
}))}
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="p-2" hideIfDisabled />
))}
{sidebarEnabled && (
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
)}
>
<SidebarIcon />
</SidebarTrigger>
)}
</LayoutHeader>
))}
{sidebarEnabled && sidebar()}
+125 -181
View File
@@ -16,132 +16,157 @@ import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCProvider, TOCScrollArea } from '@/components/toc';
import { ChildrenRenderer, renderer, type Renderer } from '@/utils/renderer';
interface BreadcrumbOptions extends BreadcrumbProps {
enabled: boolean;
component: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled: boolean;
component: ReactNode;
}
export interface DocsPageProps {
toc?: TOCItemType[];
/**
* - `multiple` (default): Accept multiple active items
* - `single`: Only accept one active item at most
* */
tocMode?: 'single' | 'multiple';
tableOfContent?: Partial<TableOfContentOptions>;
tableOfContentPopover?: Partial<TableOfContentPopoverOptions>;
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
/**
* Replace or disable breadcrumb
*/
breadcrumb?: Partial<BreadcrumbOptions>;
/**
* Footer navigation, located under the page body.
*
* You can specify `footer.children` to add extra components under the footer.
*/
footer?: Partial<FooterOptions>;
children?: ReactNode;
TOC?: Renderer<TOCMainProps>;
TOCPopover?: Renderer<TOCPopoverProps>;
/** Footer navigation, located under the page body. */
Footer?: Renderer<FooterProps>;
Container?: Renderer<ComponentProps<'div'>>;
Breadcrumb?: Renderer<BreadcrumbProps>;
/** @deprecated use `Footer` instead. */
footer?: FooterOptions;
/** @deprecated use `Container` instead. */
/**
* Apply class names to the `#nd-page` container.
*/
className?: string;
/** @deprecated use `Breadcrumb` instead. */
breadcrumb?: BreadcrumbOptions;
/** @deprecated use `TOC` instead, or `tocMode` for enabling `single`. */
tableOfContent?: TableOfContentOptions;
/** @deprecated use `TOCPopover` instead. */
tableOfContentPopover?: TableOfContentPopoverOptions;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
component?: ReactNode;
}
type TableOfContentOptions = Pick<AnchorProviderProps, 'single'> & {
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
interface FooterOptions extends FooterProps {
enabled?: boolean;
component?: ReactNode;
}
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'>, TOCProps {
enabled?: boolean;
component?: ReactNode;
}
enabled: boolean;
component: ReactNode;
interface TableOfContentPopoverOptions extends TOCProps {
enabled?: boolean;
component?: ReactNode;
}
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
};
type TableOfContentPopoverOptions = Omit<TableOfContentOptions, 'single'>;
export function DocsPage({
tableOfContent: tocProps = {},
TOC: TOCRenderer,
tableOfContentPopover: tocPopoverProps = {},
TOCPopover: TOCPopoverRenderer,
footer = {},
Footer = footer.enabled === false
? false
: footer.component
? new ChildrenRenderer(footer.component)
: footer,
className,
Container = { className },
breadcrumb = {},
Breadcrumb = breadcrumb.enabled === false
? false
: breadcrumb.component
? new ChildrenRenderer(breadcrumb.component)
: breadcrumb,
breadcrumb: { enabled: breadcrumbEnabled = true, component: breadcrumb, ...breadcrumbProps } = {},
footer: { enabled: footerEnabled, component: footerReplace, ...footerProps } = {},
full = false,
tableOfContentPopover: {
enabled: tocPopoverEnabled,
component: tocPopover,
...tocPopoverOptions
} = {},
tableOfContent: { enabled: tocEnabled, component: tocReplace, ...tocOptions } = {},
toc = [],
tocMode = tocProps.single ? 'single' : 'multiple',
children,
className,
}: DocsPageProps) {
if (!full && (tocProps.enabled ?? (toc.length > 0 || tocProps.footer || tocProps.header))) {
TOCRenderer ??= tocProps.component ? new ChildrenRenderer(tocProps.component) : tocProps;
} else {
// force disable toc in full mode or when no content
TOCRenderer = false;
// disable TOC on full mode, you can still enable it with `enabled` option.
tocEnabled ??=
!full && (toc.length > 0 || tocOptions.footer !== undefined || tocOptions.header !== undefined);
tocPopoverEnabled ??=
toc.length > 0 ||
tocPopoverOptions.header !== undefined ||
tocPopoverOptions.footer !== undefined;
let wrapper = (children: ReactNode) => children;
if (tocEnabled || tocPopoverEnabled) {
wrapper = (children) => (
<TOCProvider single={tocOptions.single} toc={toc}>
{children}
</TOCProvider>
);
}
if (
tocPopoverProps.enabled ??
(toc.length > 0 || tocPopoverProps.header || tocPopoverProps.footer)
) {
TOCPopoverRenderer ??= tocPopoverProps.component
? new ChildrenRenderer(tocPopoverProps.component)
: tocPopoverProps;
} else {
TOCPopoverRenderer = false;
}
const renderBreadcrumb = renderer(Breadcrumb, PageBreadcrumb);
const renderFooter = renderer(Footer, PageFooter);
const renderContainer = renderer(Container, 'article');
const renderToc = renderer(TOCRenderer, TOC);
const renderTocPopover = renderer(TOCPopoverRenderer, TOCPopover);
return (
<TOCProvider single={tocMode === 'single'} toc={renderToc || renderTocPopover ? toc : []}>
{renderTocPopover?.((t) => t ?? {})}
{renderContainer?.((t) => ({
id: 'nd-page',
'data-full': full,
children: (
<>
{renderBreadcrumb?.((t) => t ?? {})}
{children}
{renderFooter?.((t) => t ?? {})}
</>
),
...t,
className: cn(
return wrapper(
<>
{tocPopoverEnabled &&
(tocPopover ?? (
<PageTOCPopover>
<PageTOCPopoverTrigger />
<PageTOCPopoverContent>
{tocPopoverOptions.header}
<TOCScrollArea>
{tocPopoverOptions.style === 'clerk' ? (
<TocClerk.TOCItems />
) : (
<TocDefault.TOCItems />
)}
</TOCScrollArea>
{tocPopoverOptions.footer}
</PageTOCPopoverContent>
</PageTOCPopover>
))}
<article
id="nd-page"
data-full={full}
className={cn(
'flex flex-col w-full max-w-[900px] mx-auto [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14',
full ? 'max-w-[1168px]' : 'xl:layout:[--fd-toc-width:268px]',
t?.className,
),
}))}
{renderToc?.((t) => t ?? {})}
</TOCProvider>
className,
)}
>
{breadcrumbEnabled && (breadcrumb ?? <PageBreadcrumb {...breadcrumbProps} />)}
{children}
{footerEnabled !== false && (footerReplace ?? <PageFooter {...footerProps} />)}
</article>
{tocEnabled &&
(tocReplace ?? (
<div
id="nd-toc"
className="sticky top-(--fd-docs-row-1) h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] flex flex-col [grid-area:toc] w-(--fd-toc-width) pt-12 pe-4 pb-2 max-xl:hidden"
>
{tocOptions.header}
<h3
id="toc-title"
className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"
>
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
{tocOptions.style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{tocOptions.footer}
</div>
))}
</>,
);
}
@@ -200,86 +225,5 @@ export function DocsTitle({ children, className, ...props }: ComponentProps<'h1'
);
}
export interface TOCPopoverProps extends TOCProps {
container?: ComponentProps<typeof PageTOCPopover>;
trigger?: ComponentProps<typeof PageTOCPopoverTrigger>;
content?: ComponentProps<typeof PageTOCPopoverContent>;
}
export interface TOCMainProps extends TOCProps {
container?: ComponentProps<typeof PageTOCPopover>;
}
interface TOCProps {
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOC({ container, header, footer, style }: TOCMainProps) {
return (
<div
id="nd-toc"
{...container}
className={cn(
'sticky top-(--fd-docs-row-1) h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] flex flex-col [grid-area:toc] w-(--fd-toc-width) pt-12 pe-4 pb-2 max-xl:hidden',
container?.className,
)}
>
{header}
<h3
id="toc-title"
className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"
>
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</div>
);
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
export {
PageLastUpdate,
PageBreadcrumb,
PageFooter,
type BreadcrumbProps,
type FooterProps,
} from './client';
export { PageLastUpdate, PageBreadcrumb } from './client';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
+67 -81
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, type ReactNode, useMemo } from 'react';
import { type ComponentProps, HTMLAttributes, type ReactNode, useMemo } from 'react';
import { cn } from '@/utils/cn';
import { useSidebar } from '@/components/sidebar/base';
import { SidebarViewport, 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 { parseLayoutProps, renderTitleNav, useLinkItems } from '../shared';
import { renderTitleNav, useLinkItems } from '../shared';
import { LanguageToggle } from '../shared/language-toggle';
import { SearchToggle } from '../shared/search-toggle';
import { ThemeToggle } from '../shared/theme-toggle';
@@ -19,19 +19,18 @@ 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;
/**
* @deprecated use `Container` instead.
* Props for the `div` container
*/
containerProps?: ComponentProps<'div'>;
containerProps?: HTMLAttributes<HTMLDivElement>;
renderNavigationPanel?: (props: NavigationPanelProps) => ReactNode;
}
interface SidebarOptions
@@ -51,32 +50,24 @@ interface SidebarOptions
footer?: ReactNode;
}
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);
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) {
const tabs = useMemo(() => {
if (Array.isArray(sidebarTabs)) {
return sidebarTabs;
@@ -89,6 +80,7 @@ export function DocsLayout(_: DocsLayoutProps) {
}
return [];
}, [tree, sidebarTabs]);
const { menuItems } = useLinkItems(props);
const iconLinks = menuItems.filter((item) => item.type === 'icon');
function sidebar() {
@@ -98,28 +90,18 @@ export function DocsLayout(_: DocsLayoutProps) {
return (
<SidebarContent {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2 empty:hidden">{banner}</div>
<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>
<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>
{footer}
</SidebarContent>
);
@@ -128,17 +110,17 @@ export function DocsLayout(_: DocsLayoutProps) {
return (
<TreeContextProvider tree={tree}>
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
{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),
}))}
<div
id="nd-flux-layout"
{...containerProps}
className={cn(
'flex flex-col items-center pb-24 overflow-x-clip',
containerProps?.className,
)}
>
{sidebarEnabled && sidebar()}
{children}
</div>
{renderNavigationPanel({
head: renderTitleNav(nav, {
className: 'inline-flex items-center gap-2.5 text-sm font-semibold',
@@ -146,21 +128,25 @@ export function DocsLayout(_: DocsLayoutProps) {
tabDropdown: tabs.length > 0 && <SidebarTabsDropdown className="flex-1" options={tabs} />,
tool: (
<>
{renderLanguageSwitch?.((t) => ({
children: <Languages className="size-4.5" />,
...t,
}))}
{renderSearchToggle?.((t) => ({
hideIfDisabled: true,
...t,
className: cn('rounded-lg', t?.className),
}))}
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
</LanguageToggle>
)}
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="rounded-lg" hideIfDisabled />
))}
<NavigationSidebarTrigger />
{renderThemeSwitch?.((t) => ({
...t,
className: cn('p-1 h-full ms-1 rounded-xl bg-fd-muted *:rounded-lg', t?.className),
}))}
{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}
/>
))}
</>
),
link: iconLinks.map((item, i) => (
+74 -101
View File
@@ -16,20 +16,20 @@ import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCProvider, TOCScrollArea } from '@/components/toc';
import { ChildrenRenderer, renderer, type Renderer } from '@/utils/renderer';
interface BreadcrumbOptions extends BreadcrumbProps {
enabled: boolean;
component: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled: boolean;
component: ReactNode;
}
export interface DocsPageProps {
toc?: TOCItemType[];
/**
* - `multiple` (default): Accept multiple active items
* - `single`: Only accept one active item at most
* */
tocMode?: 'single' | 'multiple';
TOC?: Renderer<TOCProps>;
/** Footer navigation, located under the page body. */
Footer?: Renderer<FooterProps>;
Container?: Renderer<ComponentProps<'div'>>;
Breadcrumb?: Renderer<BreadcrumbProps>;
tableOfContent?: Partial<TableOfContentOptions>;
/**
* Extend the page to fill all available space
@@ -37,38 +37,28 @@ export interface DocsPageProps {
* @defaultValue false
*/
full?: boolean;
/**
* Replace or disable breadcrumb
*/
breadcrumb?: Partial<BreadcrumbOptions>;
/**
* Footer navigation, located under the page body.
*
* You can specify `footer.children` to add extra components under the footer.
*/
footer?: Partial<FooterOptions>;
children?: ReactNode;
/** @deprecated use `Footer` instead. */
footer?: FooterOptions;
/** @deprecated use `Container` instead. */
/**
* Apply class names to the `#nd-page` container.
*/
className?: string;
/** @deprecated use `Breadcrumb` instead. */
breadcrumb?: BreadcrumbOptions;
/** @deprecated use `TOC` instead, or `tocMode` for enabling `single`. */
tableOfContent?: TableOfContentOptions;
}
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'>, TOCProps {
enabled?: boolean;
component?: ReactNode;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
component?: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled?: boolean;
component?: ReactNode;
}
export interface TOCProps {
container?: ComponentProps<typeof PageTOCPopover>;
trigger?: ComponentProps<typeof PageTOCPopoverTrigger>;
content?: ComponentProps<typeof PageTOCPopoverContent>;
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'> {
/**
* Custom content in TOC container, before the main TOC
*/
@@ -79,6 +69,9 @@ export interface TOCProps {
*/
footer?: ReactNode;
enabled: boolean;
component: ReactNode;
/**
* @defaultValue 'normal'
*/
@@ -86,59 +79,56 @@ export interface TOCProps {
}
export function DocsPage({
tableOfContent: tocProps = {},
TOC: TOCRenderer,
footer = {},
Footer = footer.enabled === false
? false
: footer.component
? new ChildrenRenderer(footer.component)
: footer,
className,
Container = { className },
breadcrumb = {},
Breadcrumb = breadcrumb.enabled === false
? false
: breadcrumb.component
? new ChildrenRenderer(breadcrumb.component)
: breadcrumb,
breadcrumb: { enabled: breadcrumbEnabled = true, component: breadcrumb, ...breadcrumbProps } = {},
footer: { enabled: footerEnabled, component: footerReplace, ...footerProps } = {},
full = false,
tableOfContent: { enabled: tocPopoverEnabled, component: tocPopover, ...tocOptions } = {},
toc = [],
tocMode = tocProps.single ? 'single' : 'multiple',
children,
className,
}: DocsPageProps) {
if (tocProps.enabled ?? (toc.length > 0 || tocProps.header || tocProps.footer)) {
TOCRenderer ??= tocProps.component ? new ChildrenRenderer(tocProps.component) : tocProps;
} else {
TOCRenderer = false;
tocPopoverEnabled ??=
toc.length > 0 || tocOptions.header !== undefined || tocOptions.footer !== undefined;
let wrapper = (children: ReactNode) => children;
if (tocPopoverEnabled) {
wrapper = (children) => (
<TOCProvider single={tocOptions.single} toc={toc}>
{children}
</TOCProvider>
);
}
const renderBreadcrumb = renderer(Breadcrumb, PageBreadcrumb);
const renderFooter = renderer(Footer, PageFooter);
const renderContainer = renderer(Container, 'article');
const renderToc = renderer(TOCRenderer, TOC);
return (
<TOCProvider single={tocMode === 'single'} toc={renderToc ? toc : []}>
{renderToc?.((t) => t ?? {})}
{renderContainer?.((t) => ({
id: 'nd-page',
'data-full': full,
children: (
<>
{renderBreadcrumb?.((t) => t ?? {})}
{children}
{renderFooter?.((t) => t ?? {})}
</>
),
...t,
className: cn(
return wrapper(
<>
{tocPopoverEnabled &&
(tocPopover ?? (
<PageTOCPopover>
<PageTOCPopoverContent>
{tocOptions.header}
<TOCScrollArea>
{tocOptions.style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{tocOptions.footer}
</PageTOCPopoverContent>
<PageTOCPopoverTrigger />
</PageTOCPopover>
))}
<article
id="nd-page"
data-full={full}
className={cn(
'flex flex-col w-full max-w-[900px] mx-auto [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14',
full ? 'max-w-[1200px]' : 'xl:layout:[--fd-toc-width:268px]',
t?.className,
),
}))}
</TOCProvider>
className,
)}
>
{breadcrumbEnabled && (breadcrumb ?? <PageBreadcrumb {...breadcrumbProps} />)}
{children}
{footerEnabled !== false && (footerReplace ?? <PageFooter {...footerProps} />)}
</article>
</>,
);
}
@@ -197,21 +187,4 @@ export function DocsTitle({ children, className, ...props }: ComponentProps<'h1'
);
}
export function TOC({ container, trigger, content, header, footer, style }: TOCProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
<PageTOCPopoverTrigger {...trigger} />
</PageTOCPopover>
);
}
export { PageLastUpdate, PageBreadcrumb, PageFooter } from './client';
export type { FooterProps, BreadcrumbProps } from './client';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
export { PageLastUpdate, PageBreadcrumb } from './client';
+34 -49
View File
@@ -12,13 +12,7 @@ import {
import { cva } from 'class-variance-authority';
import Link from 'fumadocs-core/link';
import { cn } from '@/utils/cn';
import {
type LinkItemType,
type NavOptions,
parseLayoutProps,
renderTitleNav,
useLinkItems,
} from '@/layouts/shared';
import { type LinkItemType, type NavOptions, renderTitleNav, useLinkItems } from '@/layouts/shared';
import { LinkItem } from '@/utils/link-item';
import {
NavigationMenuRoot,
@@ -37,7 +31,6 @@ 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;
@@ -63,21 +56,14 @@ export const navItemVariants = cva('[&_svg]:size-4', {
},
});
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);
export function Header({
nav = {},
i18n = false,
links,
githubUrl,
themeSwitch = {},
searchToggle = {},
}: HomeLayoutProps) {
const { menuItems, navItems } = useLinkItems({ links, githubUrl });
return (
@@ -100,16 +86,20 @@ export function Header(props: HomeLayoutProps) {
))}
</NavigationMenuList>
<div className="flex flex-row items-center justify-end gap-1.5 flex-1 max-lg:hidden">
{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,
}))}
{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>
)}
<NavigationMenuList className="flex flex-row gap-2 items-center empty:hidden">
{navItems.filter(isSecondary).map((item, i) => (
<NavigationMenuLinkItem
@@ -121,11 +111,8 @@ export function Header(props: HomeLayoutProps) {
</NavigationMenuList>
</div>
<div className="flex flex-row items-center ms-auto -me-1.5 lg:hidden">
{renderSearchToggle?.((o) => ({
hideIfDisabled: true,
...o,
className: cn('p-2', o?.className),
}))}
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? <SearchToggle className="p-2" hideIfDisabled />)}
<CollapsibleTrigger
aria-label="Toggle Menu"
className={cn(
@@ -155,17 +142,15 @@ export function Header(props: HomeLayoutProps) {
/>
))}
<div role="separator" className="flex-1 sm:hidden" />
{renderLanguageSwitch?.((o) => ({
children: (
<>
<Languages className="size-5" />
<LanguageToggleText />
<ChevronDown className="size-3 text-fd-muted-foreground" />
</>
),
...o,
}))}
{renderThemeSwitch?.((t) => t ?? {})}
{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} />)}
</div>
</CollapsibleContent>
<NavigationMenu.Portal>
+23 -52
View File
@@ -2,61 +2,32 @@ 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 & {
/**
* Open mobile menu when hovering the trigger
*/
enableHoverToOpen?: boolean;
}
>;
nav?: Partial<NavOptions>;
}
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');
export function HomeLayout(props: HomeLayoutProps & ComponentProps<'main'>) {
const { nav = {}, links, githubUrl, i18n, themeSwitch = {}, searchToggle, ...rest } = props;
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}
</>
),
}));
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>
);
}
@@ -16,11 +16,10 @@ 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 & {
@@ -82,45 +81,40 @@ export function LayoutHeader(props: ComponentProps<'header'>) {
);
}
export function LayoutBody({
_,
children,
}: {
_: Renderer<ComponentProps<'div'>>;
children: ReactNode;
}) {
export function LayoutBody({ className, style, children, ...props }: ComponentProps<'div'>) {
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 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: {
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={
{
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)',
...t?.style,
},
}) as ComponentProps<'div'>,
...style,
} as object
}
{...props}
>
{children}
</div>
);
}
+254 -275
View File
@@ -1,17 +1,5 @@
import {
type ComponentProps,
createElement,
type FC,
type HTMLAttributes,
type ReactNode,
useMemo,
} from 'react';
import {
type BaseLayoutProps,
parseLayoutProps,
renderTitleNav,
useLinkItems,
} from '@/layouts/shared';
import { type ComponentProps, type FC, type HTMLAttributes, type ReactNode, useMemo } from 'react';
import { type BaseLayoutProps, renderTitleNav, useLinkItems } from '@/layouts/shared';
import {
Sidebar,
SidebarCollapseTrigger,
@@ -37,11 +25,10 @@ import {
NavbarLinkItem,
} from '@/layouts/notebook/client';
import { LargeSearchToggle, SearchToggle } from '@/layouts/shared/search-toggle';
import { LinkItem } from '@/utils/link-item';
import { LinkItem, type LinkItemType } 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;
@@ -53,12 +40,6 @@ export interface DocsLayoutProps extends BaseLayoutProps {
sidebar?: SidebarOptions;
SidebarTrigger?: Renderer<ComponentProps<'button'>>;
Container?: Renderer<ComponentProps<'div'>>;
/**
* @deprecated use `Container` instead.
*/
containerProps?: HTMLAttributes<HTMLDivElement>;
}
@@ -89,23 +70,13 @@ export function DocsLayout(props: DocsLayoutProps) {
tabMode = 'sidebar',
nav = {},
sidebar: { tabs: tabOptions, defaultOpenLevel, prefetch, ...sidebarProps } = {},
LanguageSwitch,
SearchToggle: searchToggleRenderer,
LargeSearchToggle: largeSearchToggleRenderer,
ThemeSwitch,
SidebarTrigger: SidebarTriggerRenderer = true,
containerProps,
Container = containerProps ?? true,
i18n = false,
themeSwitch = {},
tree,
} = parseLayoutProps(props);
} = 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;
@@ -124,40 +95,33 @@ export function DocsLayout(props: DocsLayoutProps) {
function sidebar() {
const { banner, footer, components, collapsible = true, ...rest } = sidebarProps;
const iconLinks = menuItems.filter((item) => item.type === 'icon');
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 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>
);
const viewport = (
<SidebarViewport>
{menuItems
@@ -177,41 +141,37 @@ export function DocsLayout(props: DocsLayoutProps) {
return (
<>
<SidebarContent {...rest}>
{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>
<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',
}),
)}
</div>
>
<SidebarIcon />
</SidebarCollapseTrigger>
)}
{tabs.length > 0 && (
<SidebarTabsDropdown
options={tabs}
className={cn(tabMode === 'navbar' && 'lg:hidden')}
/>
)}
</>
),
})}
</div>
)}
{tabs.length > 0 && (
<SidebarTabsDropdown
options={tabs}
className={cn(tabMode === 'navbar' && 'lg:hidden')}
/>
)}
</Header>
{viewport}
{renderFooter({
children: iconLinks.map((item, i) => (
<Footer>
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
@@ -226,205 +186,75 @@ export function DocsLayout(props: DocsLayoutProps) {
>
{item.icon}
</LinkItem>
)),
})}
))}
</Footer>
</SidebarContent>
<SidebarDrawer {...rest}>
{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} />}
</>
),
})}
<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>
{viewport}
{renderFooter({
className: cn(
'hidden flex-row items-center justify-end',
(renderLanguageSwitch || renderThemeSwitch) && 'flex',
iconLinks.length > 0 && 'max-lg:flex',
),
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
<Footer
className={cn(
'items-center',
navMode === 'top' && 'flex flex-1',
navMode === 'auto' && 'hidden has-data-[collapsed=true]:md:flex max-md:flex',
'hidden flex-row items-center justify-end',
(i18n || themeSwitch.enabled !== false) && 'flex',
iconLinks.length > 0 && 'max-lg:flex',
)}
>
{sidebarCollapsible && navMode === 'auto' && (
<SidebarCollapseTrigger
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
color: 'ghost',
}),
'text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden',
'text-fd-muted-foreground lg:hidden',
i === iconLinks.length - 1 && 'me-auto',
)}
aria-label={item.label}
>
<SidebarIcon />
</SidebarCollapseTrigger>
{item.icon}
</LinkItem>
))}
{i18n && (
<LanguageToggle>
<Languages className="size-4.5 text-fd-muted-foreground" />
</LanguageToggle>
)}
{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>
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle mode={themeSwitch.mode ?? 'light-dark-system'} />
))}
<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>
</Footer>
</SidebarDrawer>
</>
);
}
return (
<TreeContextProvider tree={tree}>
<LayoutContextProvider
navMode={navMode}
navMode={nav.mode ?? 'auto'}
tabMode={tabMode}
navTransparentMode={nav.transparentMode}
>
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<LayoutBody _={Container}>
<LayoutBody {...props.containerProps}>
{sidebar()}
{header()}
<DocsNavbar {...props} links={navItems} tabs={tabs} />
{props.children}
</LayoutBody>
</Sidebar>
@@ -432,3 +262,152 @@ 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>
);
}
@@ -16,21 +16,21 @@ import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCProvider, TOCScrollArea } from '@/components/toc';
import { ChildrenRenderer, renderer, type Renderer } from '@/utils/renderer';
interface BreadcrumbOptions extends BreadcrumbProps {
enabled: boolean;
component: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled: boolean;
component: ReactNode;
}
export interface DocsPageProps {
toc?: TOCItemType[];
/**
* - `multiple` (default): Accept multiple active items
* - `single`: Only accept one active item at most
* */
tocMode?: 'single' | 'multiple';
TOC?: Renderer<TOCMainProps>;
TOCPopover?: Renderer<TOCPopoverProps>;
/** Footer navigation, located under the page body. */
Footer?: Renderer<FooterProps>;
Container?: Renderer<ComponentProps<'div'>>;
Breadcrumb?: Renderer<BreadcrumbProps>;
tableOfContent?: Partial<TableOfContentOptions>;
tableOfContentPopover?: Partial<TableOfContentPopoverOptions>;
/**
* Extend the page to fill all available space
@@ -38,51 +38,28 @@ export interface DocsPageProps {
* @defaultValue false
*/
full?: boolean;
/**
* Replace or disable breadcrumb
*/
breadcrumb?: Partial<BreadcrumbOptions>;
/**
* Footer navigation, located under the page body.
*
* You can specify `footer.children` to add extra components under the footer.
*/
footer?: Partial<FooterOptions>;
children?: ReactNode;
/** @deprecated use `Footer` instead. */
footer?: FooterOptions;
/** @deprecated use `Container` instead. */
/**
* Apply class names to the `#nd-page` container.
*/
className?: string;
/** @deprecated use `Breadcrumb` instead. */
breadcrumb?: BreadcrumbOptions;
/** @deprecated use `TOC` instead, or `tocMode` for enabling `single`. */
tableOfContent?: TableOfContentOptions;
/** @deprecated use `TOCPopover` instead. */
tableOfContentPopover?: TableOfContentPopoverOptions;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
component?: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled?: boolean;
component?: ReactNode;
}
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'>, TOCProps {
enabled?: boolean;
component?: ReactNode;
}
interface TableOfContentPopoverOptions extends TOCProps {
enabled?: boolean;
component?: ReactNode;
}
export interface TOCPopoverProps extends TOCProps {
container?: ComponentProps<typeof PageTOCPopover>;
trigger?: ComponentProps<typeof PageTOCPopoverTrigger>;
content?: ComponentProps<typeof PageTOCPopoverContent>;
}
export interface TOCMainProps extends TOCProps {
container?: ComponentProps<typeof PageTOCPopover>;
}
interface TOCProps {
type TableOfContentOptions = Pick<AnchorProviderProps, 'single'> & {
/**
* Custom content in TOC container, before the main TOC
*/
@@ -93,82 +70,103 @@ interface TOCProps {
*/
footer?: ReactNode;
enabled: boolean;
component: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
};
type TableOfContentPopoverOptions = Omit<TableOfContentOptions, 'single'>;
export function DocsPage({
tableOfContent: tocProps = {},
TOC: TOCRenderer,
tableOfContentPopover: tocPopoverProps = {},
TOCPopover: TOCPopoverRenderer,
footer = {},
Footer = footer.enabled === false
? false
: footer.component
? new ChildrenRenderer(footer.component)
: footer,
className,
Container = { className },
breadcrumb = {},
Breadcrumb = breadcrumb.enabled === false
? false
: breadcrumb.component
? new ChildrenRenderer(breadcrumb.component)
: breadcrumb,
breadcrumb: { enabled: breadcrumbEnabled = true, component: breadcrumb, ...breadcrumbProps } = {},
footer: { enabled: footerEnabled, component: footerReplace, ...footerProps } = {},
full = false,
tableOfContentPopover: {
enabled: tocPopoverEnabled,
component: tocPopover,
...tocPopoverOptions
} = {},
tableOfContent: { enabled: tocEnabled, component: tocReplace, ...tocOptions } = {},
toc = [],
tocMode = tocProps.single ? 'single' : 'multiple',
children,
className,
}: DocsPageProps) {
if (!full && (tocProps.enabled ?? (toc.length > 0 || tocProps.footer || tocProps.header))) {
TOCRenderer ??= tocProps.component ? new ChildrenRenderer(tocProps.component) : tocProps;
} else {
// force disable toc in full mode or when no content
TOCRenderer = false;
// disable TOC on full mode, you can still enable it with `enabled` option.
tocEnabled ??=
!full && (toc.length > 0 || tocOptions.footer !== undefined || tocOptions.header !== undefined);
tocPopoverEnabled ??=
toc.length > 0 ||
tocPopoverOptions.header !== undefined ||
tocPopoverOptions.footer !== undefined;
let wrapper = (children: ReactNode) => children;
if (tocEnabled || tocPopoverEnabled) {
wrapper = (children) => (
<TOCProvider single={tocOptions.single} toc={toc}>
{children}
</TOCProvider>
);
}
if (
tocPopoverProps.enabled ??
(toc.length > 0 || tocPopoverProps.header || tocPopoverProps.footer)
) {
TOCPopoverRenderer ??= tocPopoverProps.component
? new ChildrenRenderer(tocPopoverProps.component)
: tocPopoverProps;
} else {
TOCPopoverRenderer = false;
}
const renderBreadcrumb = renderer(Breadcrumb, PageBreadcrumb);
const renderFooter = renderer(Footer, PageFooter);
const renderContainer = renderer(Container, 'article');
const renderToc = renderer(TOCRenderer, TOC);
const renderTocPopover = renderer(TOCPopoverRenderer, TOCPopover);
return (
<TOCProvider single={tocMode === 'single'} toc={renderToc || renderTocPopover ? toc : []}>
{renderTocPopover?.((t) => t ?? {})}
{renderContainer?.((t) => ({
id: 'nd-page',
'data-full': full,
children: (
<>
{renderBreadcrumb?.((t) => t ?? {})}
{children}
{renderFooter?.((t) => t ?? {})}
</>
),
...t,
className: cn(
return wrapper(
<>
{tocPopoverEnabled &&
(tocPopover ?? (
<PageTOCPopover>
<PageTOCPopoverTrigger />
<PageTOCPopoverContent>
{tocPopoverOptions.header}
<TOCScrollArea>
{tocPopoverOptions.style === 'clerk' ? (
<TocClerk.TOCItems />
) : (
<TocDefault.TOCItems />
)}
</TOCScrollArea>
{tocPopoverOptions.footer}
</PageTOCPopoverContent>
</PageTOCPopover>
))}
<article
id="nd-page"
data-full={full}
className={cn(
'flex flex-col [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14 *:max-w-[900px]',
full && '*:max-w-[1285px]',
t?.className,
),
}))}
{renderToc?.((t) => t ?? {})}
</TOCProvider>
className,
)}
>
{breadcrumbEnabled && (breadcrumb ?? <PageBreadcrumb {...breadcrumbProps} />)}
{children}
{footerEnabled !== false && (footerReplace ?? <PageFooter {...footerProps} />)}
</article>
{tocEnabled &&
(tocReplace ?? (
<div
id="nd-toc"
className="sticky top-(--fd-docs-row-3) [grid-area:toc] h-[calc(var(--fd-docs-height)-var(--fd-docs-row-3))] flex flex-col w-(--fd-toc-width) pt-12 pe-4 pb-2 xl:layout:[--fd-toc-width:268px] max-xl:hidden"
>
{tocOptions.header}
<h3
id="toc-title"
className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"
>
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
{tocOptions.style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{tocOptions.footer}
</div>
))}
</>,
);
}
@@ -227,54 +225,4 @@ export function DocsTitle({ children, className, ...props }: ComponentProps<'h1'
);
}
export function TOC({ container, header, footer, style }: TOCMainProps) {
return (
<div
id="nd-toc"
{...container}
className={cn(
'sticky top-(--fd-docs-row-3) [grid-area:toc] h-[calc(var(--fd-docs-height)-var(--fd-docs-row-3))] flex flex-col w-(--fd-toc-width) pt-12 pe-4 pb-2 xl:layout:[--fd-toc-width:268px] max-xl:hidden',
container?.className,
)}
>
{header}
<h3
id="toc-title"
className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"
>
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</div>
);
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
export { PageLastUpdate, PageBreadcrumb, PageFooter } from './client';
export type { FooterProps, BreadcrumbProps } from './client';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
export { PageLastUpdate, PageBreadcrumb } from './client';
+23 -58
View File
@@ -1,10 +1,7 @@
import { type HTMLAttributes, useMemo, type ComponentProps, type ReactNode } from 'react';
import { 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;
@@ -29,10 +26,27 @@ export interface NavOptions {
}
export interface BaseLayoutProps {
ThemeSwitch?: Renderer<ThemeToggleProps>;
SearchToggle?: Renderer<SearchToggleProps>;
LargeSearchToggle?: Renderer<LargeSearchToggleProps>;
LanguageSwitch?: Renderer<HTMLAttributes<HTMLElement>>;
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;
/**
* GitHub url
*/
@@ -45,55 +59,6 @@ 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'>;
}
/**
@@ -141,7 +106,7 @@ export function useLinkItems({ githubUrl, links }: Pick<BaseLayoutProps, 'links'
const menuItems: LinkItemType[] = [];
for (const item of all) {
switch (item.on) {
switch (item.on ?? 'all') {
case 'menu':
menuItems.push(item);
break;
@@ -5,7 +5,6 @@ import { cn } from '@/utils/cn';
import { useCopyButton } from '@/utils/use-copy-button';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { buttonVariants } from '@/components/ui/button';
import { usePathname } from 'fumadocs-core/framework';
const cache = new Map<string, Promise<string>>();
@@ -79,11 +78,8 @@ export function ViewOptionsPopover({
*/
githubUrl: string;
}) {
const pathname = usePathname();
const items = useMemo(() => {
const pageUrl =
typeof window === 'undefined' ? pathname : new URL(pathname, window.location.origin);
const q = `Read ${pageUrl}, I want to ask questions about it.`;
const q = `Read ${markdownUrl}, I want to ask questions about it.`;
return [
{
@@ -218,7 +214,7 @@ export function ViewOptionsPopover({
})}`,
},
];
}, [githubUrl, markdownUrl, pathname]);
}, [githubUrl, markdownUrl]);
return (
<Popover>
@@ -6,7 +6,7 @@ import { useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
import { type ButtonProps, buttonVariants } from '@/components/ui/button';
export interface SearchToggleProps extends Omit<ComponentProps<'button'>, 'color'>, ButtonProps {
interface SearchToggleProps extends Omit<ComponentProps<'button'>, 'color'>, ButtonProps {
hideIfDisabled?: boolean;
}
@@ -40,11 +40,12 @@ export function SearchToggle({
);
}
export interface LargeSearchToggleProps extends ComponentProps<'button'> {
export function LargeSearchToggle({
hideIfDisabled,
...props
}: ComponentProps<'button'> & {
hideIfDisabled?: boolean;
}
export function LargeSearchToggle({ hideIfDisabled, ...props }: LargeSearchToggleProps) {
}) {
const { enabled, hotKey, setOpenSearch } = useSearchContext();
const { text } = useI18n();
if (hideIfDisabled && !enabled) return null;
@@ -55,7 +56,7 @@ export function LargeSearchToggle({ hideIfDisabled, ...props }: LargeSearchToggl
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',
'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',
props.className,
)}
onClick={() => {
@@ -16,11 +16,13 @@ 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 interface ThemeToggleProps extends ComponentProps<'div'> {
export function ThemeToggle({
className,
mode = 'light-dark',
...props
}: 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
@@ -1,31 +0,0 @@
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) {}
}
-1
View File
@@ -9,7 +9,6 @@ export default defineConfig({
'./src/*.{ts,tsx}',
'./src/{components,tailwind,contexts,layouts,provider,og}/**/*.{ts,tsx}',
'./src/utils/use-*.{ts,tsx}',
'./src/utils/renderer.ts',
],
fixedExtension: false,
unbundle: true,
+95 -79
View File
@@ -16,94 +16,110 @@
],
"type": "module",
"exports": {
"./style.css": "./dist/style.css",
"./css/*": "./css/*",
"./image-zoom.css": "./dist/image-zoom.css",
"./components/tabs.unstyled": {
"types": "./dist/components/ui/tabs.d.ts",
"import": "./dist/components/ui/tabs.js"
},
"./components/*": {
"types": "./dist/components/*.d.ts",
"import": "./dist/components/*.js",
"default": "./dist/components/*.js"
},
"./i18n": {
"types": "./dist/i18n.d.ts",
"import": "./dist/i18n.js"
},
"./layouts/home": {
"types": "./dist/layouts/home/index.d.ts",
"import": "./dist/layouts/home/index.js"
},
"./layouts/home/not-found": {
"types": "./dist/layouts/home/not-found.d.ts",
"import": "./dist/layouts/home/not-found.js"
},
"./layouts/home/navbar": {
"types": "./dist/layouts/home/navbar.d.ts",
"import": "./dist/layouts/home/navbar.js"
},
"./layouts/docs": {
"types": "./dist/layouts/docs/index.d.ts",
"import": "./dist/layouts/docs/index.js"
},
"./layouts/docs/page": {
"types": "./dist/layouts/docs/page/index.d.ts",
"import": "./dist/layouts/docs/page/index.js"
},
"./layouts/flux": {
"types": "./dist/layouts/flux/index.d.ts",
"import": "./dist/layouts/flux/index.js"
},
"./layouts/flux/page": {
"types": "./dist/layouts/flux/page/index.d.ts",
"import": "./dist/layouts/flux/page/index.js"
},
"./layouts/notebook": {
"types": "./dist/layouts/notebook/index.d.ts",
"import": "./dist/layouts/notebook/index.js"
},
"./layouts/notebook/page": {
"types": "./dist/layouts/notebook/page/index.d.ts",
"import": "./dist/layouts/notebook/page/index.js"
},
"./layouts/shared": {
"types": "./dist/layouts/shared/index.d.ts",
"import": "./dist/layouts/shared/index.js"
},
"./page": {
"types": "./dist/page.d.ts",
"import": "./dist/page.js"
},
"./provider/*": {
"types": "./dist/provider/*.d.ts",
"import": "./dist/provider/*.js"
},
"./contexts/*": {
"types": "./dist/contexts/*.d.ts",
"import": "./dist/contexts/*.js"
},
"./components/accordion": "./dist/components/accordion.js",
"./components/banner": "./dist/components/banner.js",
"./components/callout": "./dist/components/callout.js",
"./components/card": "./dist/components/card.js",
"./components/codeblock": "./dist/components/codeblock.js",
"./components/codeblock.rsc": "./dist/components/codeblock.rsc.js",
"./components/dialog/search": "./dist/components/dialog/search.js",
"./components/dialog/search-algolia": "./dist/components/dialog/search-algolia.js",
"./components/dialog/search-default": "./dist/components/dialog/search-default.js",
"./components/dialog/search-orama": "./dist/components/dialog/search-orama.js",
"./components/dynamic-codeblock": "./dist/components/dynamic-codeblock.js",
"./components/dynamic-codeblock.core": "./dist/components/dynamic-codeblock.core.js",
"./components/files": "./dist/components/files.js",
"./components/github-info": "./dist/components/github-info.js",
"./components/heading": "./dist/components/heading.js",
"./components/image-zoom": "./dist/components/image-zoom.js",
"./components/inline-toc": "./dist/components/inline-toc.js",
"./components/sidebar/base": "./dist/components/sidebar/base.js",
"./components/sidebar/link-item": "./dist/components/sidebar/link-item.js",
"./components/sidebar/page-tree": "./dist/components/sidebar/page-tree.js",
"./components/sidebar/tabs": "./dist/components/sidebar/tabs/index.js",
"./components/sidebar/tabs/dropdown": "./dist/components/sidebar/tabs/dropdown.js",
"./components/steps": "./dist/components/steps.js",
"./components/tabs": "./dist/components/tabs.js",
"./components/toc": "./dist/components/toc/index.js",
"./components/toc/clerk": "./dist/components/toc/clerk.js",
"./components/toc/default": "./dist/components/toc/default.js",
"./components/type-table": "./dist/components/type-table.js",
"./components/ui/accordion": "./dist/components/ui/accordion.js",
"./components/ui/button": "./dist/components/ui/button.js",
"./components/ui/collapsible": "./dist/components/ui/collapsible.js",
"./components/ui/navigation-menu": "./dist/components/ui/navigation-menu.js",
"./components/ui/popover": "./dist/components/ui/popover.js",
"./components/ui/scroll-area": "./dist/components/ui/scroll-area.js",
"./components/ui/tabs": "./dist/components/ui/tabs.js",
"./contexts/i18n": "./dist/contexts/i18n.js",
"./contexts/search": "./dist/contexts/search.js",
"./contexts/tree": "./dist/contexts/tree.js",
"./i18n": "./dist/i18n.js",
"./layouts/docs": "./dist/layouts/docs/index.js",
"./layouts/docs/page": "./dist/layouts/docs/page/index.js",
"./layouts/docs/page/slots/breadcrumb": "./dist/layouts/docs/page/slots/breadcrumb.js",
"./layouts/docs/page/slots/container": "./dist/layouts/docs/page/slots/container.js",
"./layouts/docs/page/slots/footer": "./dist/layouts/docs/page/slots/footer.js",
"./layouts/docs/page/slots/toc": "./dist/layouts/docs/page/slots/toc.js",
"./layouts/docs/page/slots/toc-popover": "./dist/layouts/docs/page/slots/toc-popover.js",
"./layouts/docs/slots/container": "./dist/layouts/docs/slots/container.js",
"./layouts/docs/slots/header": "./dist/layouts/docs/slots/header.js",
"./layouts/docs/slots/sidebar": "./dist/layouts/docs/slots/sidebar.js",
"./layouts/flux": "./dist/layouts/flux/index.js",
"./layouts/flux/page": "./dist/layouts/flux/page/index.js",
"./layouts/flux/page/slots/breadcrumb": "./dist/layouts/flux/page/slots/breadcrumb.js",
"./layouts/flux/page/slots/container": "./dist/layouts/flux/page/slots/container.js",
"./layouts/flux/page/slots/footer": "./dist/layouts/flux/page/slots/footer.js",
"./layouts/flux/page/slots/toc": "./dist/layouts/flux/page/slots/toc.js",
"./layouts/flux/slots/container": "./dist/layouts/flux/slots/container.js",
"./layouts/flux/slots/sidebar": "./dist/layouts/flux/slots/sidebar.js",
"./layouts/flux/slots/tab-dropdown": "./dist/layouts/flux/slots/tab-dropdown.js",
"./layouts/home": "./dist/layouts/home/index.js",
"./layouts/home/navbar": "./dist/layouts/home/navbar.js",
"./layouts/home/not-found": "./dist/layouts/home/not-found.js",
"./layouts/home/slots/container": "./dist/layouts/home/slots/container.js",
"./layouts/home/slots/header": "./dist/layouts/home/slots/header.js",
"./layouts/notebook": "./dist/layouts/notebook/index.js",
"./layouts/notebook/page": "./dist/layouts/notebook/page/index.js",
"./layouts/notebook/page/slots/breadcrumb": "./dist/layouts/notebook/page/slots/breadcrumb.js",
"./layouts/notebook/page/slots/container": "./dist/layouts/notebook/page/slots/container.js",
"./layouts/notebook/page/slots/footer": "./dist/layouts/notebook/page/slots/footer.js",
"./layouts/notebook/page/slots/toc": "./dist/layouts/notebook/page/slots/toc.js",
"./layouts/notebook/page/slots/toc-popover": "./dist/layouts/notebook/page/slots/toc-popover.js",
"./layouts/notebook/slots/container": "./dist/layouts/notebook/slots/container.js",
"./layouts/notebook/slots/header": "./dist/layouts/notebook/slots/header.js",
"./layouts/notebook/slots/sidebar": "./dist/layouts/notebook/slots/sidebar.js",
"./layouts/shared": "./dist/layouts/shared/index.js",
"./layouts/shared/slots/language-select": "./dist/layouts/shared/slots/language-select.js",
"./layouts/shared/slots/search-trigger": "./dist/layouts/shared/slots/search-trigger.js",
"./layouts/shared/slots/theme-switch": "./dist/layouts/shared/slots/theme-switch.js",
"./mdx": {
"types": "./dist/mdx.d.ts",
"node": "./dist/mdx.server.js",
"import": "./dist/mdx.js"
},
"./og": {
"types": "./dist/og.d.ts",
"import": "./dist/og.js"
},
"./og/*": {
"types": "./dist/og/*.d.ts",
"import": "./dist/og/*.js"
},
"./og": "./dist/og.js",
"./og/takumi": "./dist/og/takumi.js",
"./page": "./dist/page.js",
"./provider/base": "./dist/provider/base.js",
"./provider/next": "./dist/provider/next.js",
"./provider/react-router": "./dist/provider/react-router.js",
"./provider/tanstack": "./dist/provider/tanstack.js",
"./provider/waku": "./dist/provider/waku.js",
"./utils/use-copy-button": "./dist/utils/use-copy-button.js",
"./utils/use-footer-items": "./dist/utils/use-footer-items.js",
"./utils/use-is-scroll-top": "./dist/utils/use-is-scroll-top.js",
"./package.json": "./package.json",
"./style.css": "./dist/style.css",
"./css/*": "./css/*",
"./image-zoom.css": "./dist/image-zoom.css",
"./utils/get-sidebar-tabs": {
"types": "./dist/components/sidebar/tabs/index.d.ts",
"import": "./dist/components/sidebar/tabs/index.js"
},
"./utils/*": {
"types": "./dist/utils/*.d.ts",
"import": "./dist/utils/*.js"
"./components/tabs.unstyled": {
"types": "./dist/components/ui/tabs.d.ts",
"import": "./dist/components/ui/tabs.js"
}
},
"publishConfig": {
+8 -2
View File
@@ -22,6 +22,7 @@ export const registry: Registry = {
...(await findSlotComponents(dir)),
{
name: 'layouts/sidebar',
unlisted: true,
files: [
{
type: 'components',
@@ -57,7 +58,12 @@ export const registry: Registry = {
{
type: 'components',
path: 'layouts/shared/index.tsx',
target: '<dir>/layout/shared.tsx',
target: '<dir>/layout/shared/index.tsx',
},
{
type: 'components',
path: 'layouts/shared/client.tsx',
target: '<dir>/layout/shared/client.tsx',
},
],
},
@@ -327,7 +333,7 @@ export const registry: Registry = {
files: [
{
type: 'components',
path: 'layouts/slots/page-actions.tsx',
path: 'layouts/shared/page-actions.tsx',
target: '<dir>/ai/page-actions.tsx',
},
],
@@ -1,7 +1,8 @@
'use client';
import type { HTMLAttributes } from 'react';
import type * as Base from './base';
import { useLinkItemActive, type LinkItemType } from '@/utils/link-item';
import { isLinkItemActive, type LinkItemType } from '@/layouts/shared';
import { usePathname } from 'fumadocs-core/framework';
type InternalComponents = Pick<
typeof Base,
@@ -28,7 +29,8 @@ export function createLinkItemRenderer({
}: HTMLAttributes<HTMLElement> & {
item: Exclude<LinkItemType, { type: 'icon' }>;
}) {
const active = useLinkItemActive(item);
const pathname = usePathname();
const active = isLinkItemActive(item, pathname);
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
if (item.type === 'menu')
@@ -5,7 +5,7 @@ import { cn } from '@/utils/cn';
import { usePathname } from 'fumadocs-core/framework';
import Link from 'fumadocs-core/link';
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
import type { LinkItemType } from '@/utils/link-item';
import type { LinkItemType } from '@/layouts/shared';
import {
Sidebar,
SidebarProvider,
@@ -44,11 +44,13 @@ const { useProvider } = baseSlots({
},
});
type SlotsProps = BaseSlotsProps & Pick<DocsLayoutProps, 'tabMode'>;
interface SlotsProps extends BaseSlotsProps<DocsLayoutProps> {
tabs: LayoutTab[];
tabMode: NonNullable<DocsLayoutProps['tabMode']>;
}
const LayoutContext = createContext<{
props: SlotsProps;
tabs: LayoutTab[];
isNavTransparent: boolean;
navItems: LinkItemType[];
menuItems: LinkItemType[];
@@ -131,9 +133,9 @@ export function LayoutBody(
value={{
props: {
tabMode,
tabs,
...baseProps,
},
tabs,
isNavTransparent,
slots,
...linkItems,
+3 -3
View File
@@ -36,9 +36,9 @@ interface SidebarOptions extends SidebarProps, SidebarProviderProps {
export function DocsLayout({
tree,
sidebar: { tabs: defaultTabs, tabMode: defaultTabMode, ...sidebarProps } = {},
tabs: layoutTabs = defaultTabs,
tabMode = defaultTabMode,
sidebar: { tabs: _tabs, tabMode: _tabMode, ...sidebarProps } = {},
tabs: layoutTabs = _tabs,
tabMode = _tabMode,
children,
...props
}: DocsLayoutProps) {
@@ -77,10 +77,11 @@ export interface DocsPageSlots {
breadcrumb?: FC<BreadcrumbProps>;
}
type PageSlotsProps = Pick<
DocsPageProps,
'full' | 'breadcrumb' | 'footer' | 'tableOfContent' | 'tableOfContentPopover'
>;
interface PageSlotsProps extends Pick<DocsPageProps, 'full' | 'breadcrumb' | 'footer'> {
tableOfContent: TOCProps & { component?: ReactNode };
tableOfContentPopover: TOCPopoverProps & { component?: ReactNode };
}
const PageContext = createContext<{
props: PageSlotsProps;
slots: DocsPageSlots;
@@ -122,7 +123,13 @@ export function DocsPage({
let content = (
<>
{slots.tocPopover && <slots.tocPopover />}
{slots.container && <slots.container {...containerProps}>{children}</slots.container>}
{slots.container && (
<slots.container {...containerProps}>
{slots.breadcrumb && <slots.breadcrumb />}
{children}
{slots.footer && <slots.footer />}
</slots.container>
)}
{slots.toc && <slots.toc />}
</>
);
@@ -153,27 +160,27 @@ export function DocsPage({
}
function InlineBreadcrumb(props: BreadcrumbProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.breadcrumb ?? {};
const { component, enabled: _, ...rest } = useDocsPage().props.breadcrumb ?? {};
if (component) return component;
return <Breadcrumb {...props} {...rest} />;
}
function InlineFooter(props: FooterProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.footer ?? {};
const { component, enabled: _, ...rest } = useDocsPage().props.footer ?? {};
if (component) return component;
return <Footer {...props} {...rest} />;
}
function InlineTOCPopover(props: TOCPopoverProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.tableOfContentPopover ?? {};
if (component) return component;
return <TOCPopover {...props} {...rest} />;
const { tableOfContentPopover } = useDocsPage().props;
if (tableOfContentPopover.component) return tableOfContentPopover.component;
return <TOCPopover {...props} {...tableOfContentPopover} />;
}
function InlineTOC(props: TOCProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.tableOfContent ?? {};
if (component) return component;
return <TOC {...props} {...rest} />;
const { tableOfContent } = useDocsPage().props;
if (tableOfContent.component) return tableOfContent.component;
return <TOC {...props} {...tableOfContent} />;
}
export function PageLastUpdate({
@@ -66,8 +66,6 @@ export {
PageLastUpdate,
useDocsPage,
} from './client';
export { TOC, type TOCProps } from './slots/toc';
export { TOCPopover, type TOCPopoverProps } from './slots/toc-popover';
export { type BreadcrumbProps, Breadcrumb as PageBreadcrumb } from './slots/breadcrumb';
export { type FooterProps, Footer as PageFooter } from './slots/footer';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/slots/page-actions';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
@@ -4,7 +4,6 @@ import { cn } from '@/utils/cn';
export function Container(props: ComponentProps<'article'>) {
const {
slots,
props: { full },
} = useDocsPage();
@@ -19,9 +18,7 @@ export function Container(props: ComponentProps<'article'>) {
props.className,
)}
>
{slots.breadcrumb && <slots.breadcrumb />}
{props.children}
{slots.footer && <slots.footer />}
</article>
);
}
@@ -1,7 +1,7 @@
'use client';
import * as Base from '@/components/sidebar/base';
import { cn } from '@/utils/cn';
import { type ComponentProps, type ReactNode, useRef } from 'react';
import { type ComponentProps, type ReactNode, useMemo, useRef, useState } from 'react';
import { cva } from 'class-variance-authority';
import {
createPageTreeRenderer,
@@ -9,13 +9,16 @@ import {
} from '@/components/sidebar/page-tree';
import { createLinkItemRenderer } from '@/components/sidebar/link-item';
import { buttonVariants } from '@/components/ui/button';
import { SearchTrigger } from '@/layouts/slots/search-toggle';
import { Languages, Sidebar as SidebarIcon } from 'lucide-react';
import { SearchTrigger } from '@/layouts/shared/slots/search-trigger';
import { Check, ChevronsUpDown, Languages, Sidebar as SidebarIcon } from 'lucide-react';
import { mergeRefs } from '@/utils/merge-refs';
import { ScrollArea, ScrollAreaProps, ScrollViewport } from '@/components/ui/scroll-area';
import { SidebarTabsDropdown } from '@/components/sidebar/tabs/dropdown';
import { useDocsLayout } from '../client';
import { LinkItem } from '@/utils/link-item';
import { LinkItem } from '@/layouts/shared';
import { isLayoutTabActive, type LayoutTab } from '@/layouts/shared';
import { usePathname } from 'fumadocs-core/framework';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import Link from 'fumadocs-core/link';
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0',
@@ -56,10 +59,9 @@ export function SidebarProvider(props: SidebarProviderProps) {
export function Sidebar({ footer, banner, collapsible = true, components, ...rest }: SidebarProps) {
const {
tabs,
menuItems,
slots,
props: { nav, tabMode = 'auto' },
props: { tabs, nav, tabMode },
} = useDocsLayout();
const iconLinks = menuItems.filter((item) => item.type === 'icon');
const viewport = (
@@ -97,7 +99,7 @@ export function Sidebar({ footer, banner, collapsible = true, components, ...res
)}
</div>
{slots.searchTrigger && <slots.searchTrigger.full hideIfDisabled />}
{tabs.length > 0 && tabMode === 'auto' && <SidebarTabsDropdown options={tabs} />}
{tabs.length > 0 && tabMode === 'auto' && <SidebarTabsDropdown tabs={tabs} />}
{banner}
</div>
{viewport}
@@ -165,7 +167,7 @@ export function Sidebar({ footer, banner, collapsible = true, components, ...res
<SidebarIcon />
</SidebarTrigger>
</div>
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
{tabs.length > 0 && <SidebarTabsDropdown tabs={tabs} />}
{banner}
</div>
{viewport}
@@ -395,6 +397,93 @@ function SidebarFolderContent({
);
}
function SidebarTabsDropdown({
tabs,
placeholder,
...props
}: {
placeholder?: ReactNode;
tabs: LayoutTab[];
} & ComponentProps<'button'>) {
const [open, setOpen] = useState(false);
const { closeOnRedirect } = useSidebar();
const pathname = usePathname();
const selected = useMemo(() => {
return tabs.findLast((item) => isLayoutTabActive(item, pathname));
}, [tabs, pathname]);
const onClick = () => {
closeOnRedirect.current = false;
setOpen(false);
};
const item = selected ? (
<>
<div className="size-9 shrink-0 empty:hidden md:size-5">{selected.icon}</div>
<div>
<p className="text-sm font-medium">{selected.title}</p>
<p className="text-sm text-fd-muted-foreground empty:hidden md:hidden">
{selected.description}
</p>
</div>
</>
) : (
placeholder
);
return (
<Popover open={open} onOpenChange={setOpen}>
{item && (
<PopoverTrigger
{...props}
className={cn(
'flex items-center gap-2 rounded-lg p-2 border bg-fd-secondary/50 text-start text-fd-secondary-foreground transition-colors hover:bg-fd-accent data-[state=open]:bg-fd-accent data-[state=open]:text-fd-accent-foreground',
props.className,
)}
>
{item}
<ChevronsUpDown className="shrink-0 ms-auto size-4 text-fd-muted-foreground" />
</PopoverTrigger>
)}
<PopoverContent className="flex flex-col gap-1 w-(--radix-popover-trigger-width) p-1 fd-scroll-container">
{tabs.map((item) => {
const isActive = selected && item.url === selected.url;
if (!isActive && item.unlisted) return;
return (
<Link
key={item.url}
href={item.url}
onClick={onClick}
{...item.props}
className={cn(
'flex items-center gap-2 rounded-lg p-1.5 hover:bg-fd-accent hover:text-fd-accent-foreground',
item.props?.className,
)}
>
<div className="shrink-0 size-9 md:mb-auto md:size-5 empty:hidden">{item.icon}</div>
<div>
<p className="text-sm font-medium leading-none">{item.title}</p>
<p className="text-[0.8125rem] text-fd-muted-foreground mt-1 empty:hidden">
{item.description}
</p>
</div>
<Check
className={cn(
'shrink-0 ms-auto size-3.5 text-fd-primary',
!isActive && 'invisible',
)}
/>
</Link>
);
})}
</PopoverContent>
</Popover>
);
}
function getItemOffset(depth: number) {
return `calc(${2 + 3 * depth} * var(--spacing))`;
}
+15 -14
View File
@@ -16,7 +16,7 @@ import { cn } from '@/utils/cn';
import { TabDropdown, type TabDropdownProps } from './slots/tab-dropdown';
import { buttonVariants } from '@/components/ui/button';
import { Languages } from 'lucide-react';
import { LinkItem, type LinkItemType } from '@/utils/link-item';
import { LinkItem, type LinkItemType } from '@/layouts/shared';
import { motion } from 'motion/react';
import { RemoveScroll } from 'react-remove-scroll';
import { useSearchContext } from '@/contexts/search';
@@ -87,12 +87,12 @@ export function DocsLayout(props: DocsLayoutProps) {
nav = {},
sidebar: {
enabled: sidebarEnabled = true,
tabs: sidebarTabs,
tabs: _tabs,
defaultOpenLevel,
prefetch,
...sidebarProps
} = {},
tabs: defaultTabs = sidebarTabs,
tabs: defaultTabs = _tabs,
children,
containerProps,
renderNavigationPanel = (props) => <NavigationPanel {...props} />,
@@ -113,7 +113,6 @@ export function DocsLayout(props: DocsLayoutProps) {
}
return [];
}, [tree, defaultTabs]);
const iconLinks = linkItems.menuItems.filter((item) => item.type === 'icon');
const slots: DocsSlots = {
...baseSlots,
container: defaultSlots.container ?? Container,
@@ -193,16 +192,18 @@ export function DocsLayout(props: DocsLayoutProps) {
)}
</>
),
link: iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(buttonVariants({ size: 'icon-sm', color: 'ghost' }))}
aria-label={item.label}
>
{item.icon}
</LinkItem>
)),
link: linkItems.menuItems
.filter((item) => item.type === 'icon')
.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(buttonVariants({ size: 'icon-sm', color: 'ghost' }))}
aria-label={item.label}
>
{item.icon}
</LinkItem>
)),
})}
</TreeContextProvider>
</LayoutContext>
@@ -1,14 +1,22 @@
'use client';
import { type ComponentProps, createContext, type FC, use, useEffect, useState } from 'react';
import {
type ComponentProps,
createContext,
type FC,
type ReactNode,
use,
useEffect,
useState,
} from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TOC, type TOCProps } from './slots/toc';
import { Footer, type FooterProps } from './slots/footer';
import { Breadcrumb, type BreadcrumbProps } from './slots/breadcrumb';
import type { DocsPageProps } from '.';
import { TOCProvider, type TOCProviderProps } from '@/components/toc';
import { Container } from './slots/container';
import type { TOCItemType } from 'fumadocs-core/toc';
export interface DocsPageSlots {
toc?: FC<TOCProps>;
@@ -18,7 +26,51 @@ export interface DocsPageSlots {
breadcrumb?: FC<BreadcrumbProps>;
}
type PageSlotsProps = Pick<DocsPageProps, 'full' | 'breadcrumb' | 'footer' | 'tableOfContent'>;
export interface DocsPageProps extends ComponentProps<'article'> {
toc?: TOCItemType[];
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
children?: ReactNode;
slots?: DocsPageSlots;
footer?: FooterOptions;
breadcrumb?: BreadcrumbOptions;
tableOfContent?: TableOfContentOptions;
}
interface TableOfContentOptions extends Pick<TOCProviderProps, 'single'>, TOCProps {
enabled?: boolean;
/**
* @deprecated use `slots.toc` instead.
*/
component?: ReactNode;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
/**
* @deprecated use `slots.breadcrumb` instead.
*/
component?: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled?: boolean;
/**
* @deprecated use `slots.footer` instead.
*/
component?: ReactNode;
}
interface PageSlotsProps extends Pick<DocsPageProps, 'full' | 'footer' | 'breadcrumb'> {
tableOfContent: TOCProps & { component?: ReactNode };
}
const PageContext = createContext<{
props: PageSlotsProps;
slots: DocsPageSlots;
@@ -57,7 +109,13 @@ export function DocsPage({
let content = (
<>
{slots.toc && <slots.toc />}
{slots.container && <slots.container {...containerProps}>{children}</slots.container>}
{slots.container && (
<slots.container {...containerProps}>
{slots.breadcrumb && <slots.breadcrumb />}
{children}
{slots.footer && <slots.footer />}
</slots.container>
)}
</>
);
@@ -86,21 +144,21 @@ export function DocsPage({
}
function InlineBreadcrumb(props: BreadcrumbProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.breadcrumb ?? {};
const { component, enabled: _, ...rest } = useDocsPage().props.breadcrumb ?? {};
if (component) return component;
return <Breadcrumb {...props} {...rest} />;
}
function InlineFooter(props: FooterProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.footer ?? {};
const { component, enabled: _, ...rest } = useDocsPage().props.footer ?? {};
if (component) return component;
return <Footer {...props} {...rest} />;
}
function InlineTOC(props: TOCProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.tableOfContent ?? {};
if (component) return component;
return <TOC {...props} {...rest} />;
const { tableOfContent } = useDocsPage().props;
if (tableOfContent.component) return tableOfContent.component;
return <TOC {...props} {...tableOfContent} />;
}
export function PageLastUpdate({
@@ -1,54 +1,8 @@
import type { ComponentProps, ReactNode } from 'react';
import type { ComponentProps } from 'react';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { Edit } from 'lucide-react';
import { I18nLabel } from '@/contexts/i18n';
import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
import type { DocsPageSlots } from './client';
import type { TOCProps } from './slots/toc';
import type { BreadcrumbProps } from './slots/breadcrumb';
import type { FooterProps } from './slots/footer';
export interface DocsPageProps extends ComponentProps<'article'> {
toc?: TOCItemType[];
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
children?: ReactNode;
slots?: DocsPageSlots;
footer?: FooterOptions;
breadcrumb?: BreadcrumbOptions;
tableOfContent?: TableOfContentOptions;
}
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'>, TOCProps {
enabled?: boolean;
/**
* @deprecated use `slots.toc` instead.
*/
component?: ReactNode;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
/**
* @deprecated use `slots.breadcrumb` instead.
*/
component?: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled?: boolean;
/**
* @deprecated use `slots.footer` instead.
*/
component?: ReactNode;
}
export function EditOnGitHub(props: ComponentProps<'a'>) {
return (
@@ -105,8 +59,13 @@ export function DocsTitle({ children, className, ...props }: ComponentProps<'h1'
);
}
export { DocsPage, type DocsPageSlots, PageLastUpdate, useDocsPage } from './client';
export { TOC, type TOCProps } from './slots/toc';
export {
DocsPage,
type DocsPageSlots,
type DocsPageProps,
PageLastUpdate,
useDocsPage,
} from './client';
export { type FooterProps, Footer as PageFooter } from './slots/footer';
export { type BreadcrumbProps, Breadcrumb as PageBreadcrumb } from './slots/breadcrumb';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/slots/page-actions';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
@@ -4,7 +4,6 @@ import { useDocsPage } from '../client';
export function Container(props: ComponentProps<'article'>) {
const {
slots,
props: { full },
} = useDocsPage();
@@ -19,9 +18,7 @@ export function Container(props: ComponentProps<'article'>) {
props.className,
)}
>
{slots.breadcrumb && <slots.breadcrumb />}
{props.children}
{slots.footer && <slots.footer />}
</article>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@
import type { BaseLayoutProps, NavOptions } from '@/layouts/shared';
import { type ComponentProps, createContext, type FC, use } from 'react';
import { baseSlots, useLinkItems, type BaseSlots, type BaseSlotsProps } from '@/layouts/shared';
import type { LinkItemType } from '@/utils/link-item';
import type { LinkItemType } from '@/layouts/shared';
import { Container } from './slots/container';
import { Header } from './slots/header';
@@ -4,7 +4,7 @@ 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, LinkItem } from '@/utils/link-item';
import { type LinkItemType, LinkItem } from '@/layouts/shared';
import {
NavigationMenu,
NavigationMenuContent,
@@ -2,8 +2,14 @@
import { type ComponentProps, createContext, type FC, use } from 'react';
import { type DocsLayoutProps } from '.';
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
import { type LinkItemType } from '@/utils/link-item';
import { baseSlots, type BaseSlots, type BaseSlotsProps, LayoutTab, useLinkItems } from '../shared';
import { type LinkItemType } from '@/layouts/shared';
import {
baseSlots,
type BaseSlots,
type BaseSlotsProps,
type LayoutTab,
useLinkItems,
} from '../shared';
import { TreeContextProvider } from '@/contexts/tree';
import { Container } from './slots/container';
import {
@@ -35,18 +41,18 @@ const { useProvider } = baseSlots({
},
});
type SlotsProps = BaseSlotsProps;
interface SlotsProps extends BaseSlotsProps<DocsLayoutProps> {
sidebar: SidebarProps;
tabMode: NonNullable<DocsLayoutProps['tabMode']>;
tabs: LayoutTab[];
}
const LayoutContext = createContext<{
props: SlotsProps;
tabs: LayoutTab[];
sidebarCollapsible: boolean;
isNavTransparent: boolean;
navItems: LinkItemType[];
menuItems: LinkItemType[];
slots: DocsSlots;
tabMode: 'sidebar' | 'navbar';
navMode: 'top' | 'auto';
} | null>(null);
export function useNotebookLayout() {
@@ -64,11 +70,7 @@ export function LayoutBody(
},
) {
const {
nav: {
enabled: navEnabled = true,
mode: navMode = 'auto',
transparentMode: navTransparentMode = 'none',
} = {},
nav: { enabled: navEnabled = true, transparentMode: navTransparentMode = 'none' } = {},
sidebar: { defaultOpenLevel, prefetch, ...sidebarProps } = {},
slots: defaultSlots,
tabMode = 'sidebar',
@@ -118,11 +120,12 @@ export function LayoutBody(
<TreeContextProvider tree={tree}>
<LayoutContext
value={{
sidebarCollapsible: sidebarProps.collapsible ?? true,
props: baseProps,
tabMode,
navMode,
tabs,
props: {
tabs,
tabMode,
sidebar: sidebarProps,
...baseProps,
},
isNavTransparent,
slots,
...linkItems,
@@ -1,15 +1,23 @@
'use client';
import { type ComponentProps, createContext, type FC, use, useEffect, useState } from 'react';
import {
type ComponentProps,
createContext,
type FC,
type ReactNode,
use,
useEffect,
useState,
} from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TOC, type TOCProps } from './slots/toc';
import { TOCPopover, type TOCPopoverProps } from './slots/toc-popover';
import { Footer, type FooterProps } from './slots/footer';
import { Breadcrumb, type BreadcrumbProps } from './slots/breadcrumb';
import type { DocsPageProps } from '.';
import { TOCProvider, type TOCProviderProps } from '@/components/toc';
import { Container } from './slots/container';
import type { TOCItemType } from 'fumadocs-core/toc';
export interface DocsPageSlots {
toc?: FC<TOCProps>;
@@ -20,10 +28,59 @@ export interface DocsPageSlots {
breadcrumb?: FC<BreadcrumbProps>;
}
type PageSlotsProps = Pick<
DocsPageProps,
'full' | 'breadcrumb' | 'footer' | 'tableOfContent' | 'tableOfContentPopover'
>;
export interface DocsPageProps extends ComponentProps<'article'> {
toc?: TOCItemType[];
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
slots?: DocsPageSlots;
footer?: FooterOptions;
breadcrumb?: BreadcrumbOptions;
tableOfContent?: TableOfContentOptions;
tableOfContentPopover?: TableOfContentPopoverOptions;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
/**
* @deprecated use `slots.breadcrumb` instead.
*/
component?: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled?: boolean;
/**
* @deprecated use `slots.footer` instead.
*/
component?: ReactNode;
}
interface TableOfContentOptions extends Pick<TOCProviderProps, 'single'>, TOCProps {
enabled?: boolean;
/**
* @deprecated use `slots.toc` instead.
*/
component?: ReactNode;
}
interface TableOfContentPopoverOptions extends TOCPopoverProps {
enabled?: boolean;
/**
* @deprecated use `slots.tocPopover` instead.
*/
component?: ReactNode;
}
interface PageSlotsProps extends Pick<DocsPageProps, 'full' | 'breadcrumb' | 'footer'> {
tableOfContent: TOCProps & { component?: ReactNode };
tableOfContentPopover: TOCPopoverProps & { component?: ReactNode };
}
const PageContext = createContext<{
props: PageSlotsProps;
slots: DocsPageSlots;
@@ -65,7 +122,13 @@ export function DocsPage({
let content = (
<>
{slots.tocPopover && <slots.tocPopover />}
{slots.container && <slots.container {...containerProps}>{children}</slots.container>}
{slots.container && (
<slots.container {...containerProps}>
{slots.breadcrumb && <slots.breadcrumb />}
{children}
{slots.footer && <slots.footer />}
</slots.container>
)}
{slots.toc && <slots.toc />}
</>
);
@@ -96,27 +159,27 @@ export function DocsPage({
}
function InlineBreadcrumb(props: BreadcrumbProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.breadcrumb ?? {};
const { component, enabled: _, ...rest } = useDocsPage().props.breadcrumb ?? {};
if (component) return component;
return <Breadcrumb {...props} {...rest} />;
}
function InlineFooter(props: FooterProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.footer ?? {};
const { component, enabled: _, ...rest } = useDocsPage().props.footer ?? {};
if (component) return component;
return <Footer {...props} {...rest} />;
}
function InlineTOCPopover(props: TOCPopoverProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.tableOfContentPopover ?? {};
if (component) return component;
return <TOCPopover {...props} {...rest} />;
const { tableOfContentPopover } = useDocsPage().props;
if (tableOfContentPopover.component) return tableOfContentPopover.component;
return <TOCPopover {...props} {...tableOfContentPopover} />;
}
function InlineTOC(props: TOCProps) {
const { component, enabled: _, ...rest } = useDocsPage().props?.tableOfContent ?? {};
if (component) return component;
return <TOC {...props} {...rest} />;
const { tableOfContent } = useDocsPage().props;
if (tableOfContent.component) return tableOfContent.component;
return <TOC {...props} {...tableOfContent} />;
}
export function PageLastUpdate({
@@ -1,62 +1,8 @@
import type { ComponentProps, ReactNode } from 'react';
import type { ComponentProps } from 'react';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { Edit } from 'lucide-react';
import { I18nLabel } from '@/contexts/i18n';
import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
import type { DocsPageSlots } from './client';
import type { TOCProps } from './slots/toc';
import type { TOCPopoverProps } from './slots/toc-popover';
import type { BreadcrumbProps } from './slots/breadcrumb';
import type { FooterProps } from './slots/footer';
export interface DocsPageProps extends ComponentProps<'article'> {
toc?: TOCItemType[];
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
slots?: DocsPageSlots;
footer?: FooterOptions;
breadcrumb?: BreadcrumbOptions;
tableOfContent?: TableOfContentOptions;
tableOfContentPopover?: TableOfContentPopoverOptions;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
/**
* @deprecated use `slots.breadcrumb` instead.
*/
component?: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled?: boolean;
/**
* @deprecated use `slots.footer` instead.
*/
component?: ReactNode;
}
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'>, TOCProps {
enabled?: boolean;
/**
* @deprecated use `slots.toc` instead.
*/
component?: ReactNode;
}
interface TableOfContentPopoverOptions extends TOCPopoverProps {
enabled?: boolean;
/**
* @deprecated use `slots.tocPopover` instead.
*/
component?: ReactNode;
}
export function EditOnGitHub(props: ComponentProps<'a'>) {
return (
@@ -113,9 +59,13 @@ export function DocsTitle({ children, className, ...props }: ComponentProps<'h1'
);
}
export { DocsPage, type DocsPageSlots, PageLastUpdate, useDocsPage } from './client';
export { TOC, type TOCProps } from './slots/toc';
export { TOCPopover, type TOCPopoverProps } from './slots/toc-popover';
export {
DocsPage,
type DocsPageProps,
type DocsPageSlots,
PageLastUpdate,
useDocsPage,
} from './client';
export { type BreadcrumbProps, Breadcrumb as PageBreadcrumb } from './slots/breadcrumb';
export { type FooterProps, Footer as PageFooter } from './slots/footer';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/slots/page-actions';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
@@ -4,7 +4,6 @@ import { cn } from '@/utils/cn';
export function Container(props: ComponentProps<'article'>) {
const {
slots,
props: { full },
} = useDocsPage();
@@ -19,9 +18,7 @@ export function Container(props: ComponentProps<'article'>) {
props.className,
)}
>
{slots.breadcrumb && <slots.breadcrumb />}
{props.children}
{slots.footer && <slots.footer />}
</article>
);
}
@@ -3,7 +3,10 @@ import type { ComponentProps } from 'react';
import { useNotebookLayout } from '../client';
export function Container(props: ComponentProps<'div'>) {
const { navMode, slots } = useNotebookLayout();
const {
props: { nav },
slots,
} = useNotebookLayout();
const pageCol =
'calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-col) - var(--fd-toc-width))';
const { collapsed } = slots.sidebar?.useSidebar?.() ?? {};
@@ -16,7 +19,7 @@ export function Container(props: ComponentProps<'div'>) {
style={
{
gridTemplate:
navMode === 'top'
nav?.mode === '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)`
@@ -11,7 +11,7 @@ import {
} from 'react';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { LinkItem, type LinkItemType, type MenuItemType } from '@/utils/link-item';
import { LinkItem, type LinkItemType, type MenuItemType } from '@/layouts/shared';
import { useNotebookLayout } from '../client';
import { type LayoutTab, isLayoutTabActive } from '@/layouts/shared';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
@@ -21,15 +21,13 @@ import Link from 'fumadocs-core/link';
export function Header(props: ComponentProps<'header'>) {
const {
slots,
tabs,
navItems,
isNavTransparent,
sidebarCollapsible,
tabMode,
navMode,
props: { nav },
props: { tabMode, nav, tabs, sidebar },
} = useNotebookLayout();
const { open } = slots.sidebar?.useSidebar?.() ?? {};
const navMode = nav?.mode ?? 'auto';
const sidebarCollapsible = sidebar.collapsible ?? true;
const showLayoutTabs = tabMode === 'navbar' && tabs.length > 0;
if (nav?.component) return nav.component;
@@ -2,7 +2,15 @@
import * as Base from '@/components/sidebar/base';
import { cn } from '@/utils/cn';
import { type ComponentProps, createElement, type FC, type ReactNode, useRef } from 'react';
import {
type ComponentProps,
createElement,
type FC,
type ReactNode,
useMemo,
useRef,
useState,
} from 'react';
import { cva } from 'class-variance-authority';
import {
createPageTreeRenderer,
@@ -12,10 +20,13 @@ import { createLinkItemRenderer } from '@/components/sidebar/link-item';
import { buttonVariants } from '@/components/ui/button';
import { mergeRefs } from '@/utils/merge-refs';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { SidebarTabsDropdown } from '@/components/sidebar/tabs/dropdown';
import { LinkItem } from '@/utils/link-item';
import { Languages, Sidebar as SidebarIcon, X } from 'lucide-react';
import { LinkItem } from '@/layouts/shared';
import { Check, ChevronsUpDown, Languages, Sidebar as SidebarIcon, X } from 'lucide-react';
import { useNotebookLayout } from '../client';
import { isLayoutTabActive, type LayoutTab } from '@/layouts/shared';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { usePathname } from 'fumadocs-core/framework';
import Link from 'fumadocs-core/link';
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0',
@@ -84,7 +95,10 @@ function SidebarViewport(props: ComponentProps<typeof ScrollArea>) {
}
function SidebarContent({ ref: refProp, className, children, ...props }: ComponentProps<'aside'>) {
const { navMode } = useNotebookLayout();
const {
props: { nav },
} = useNotebookLayout();
const navMode = nav?.mode ?? 'auto';
const ref = useRef<HTMLElement>(null);
return (
@@ -281,13 +295,11 @@ const SidebarLinkItem = createLinkItemRenderer({
export function Sidebar({ banner, footer, components, collapsible = true, ...rest }: SidebarProps) {
const {
tabs,
menuItems,
slots,
tabMode,
navMode,
props: { nav },
props: { nav, tabs, tabMode },
} = useNotebookLayout();
const navMode = nav?.mode ?? 'auto';
const iconLinks = menuItems.filter((item) => item.type === 'icon');
function renderHeader(props: ComponentProps<'div'>) {
@@ -448,3 +460,90 @@ export function Sidebar({ banner, footer, components, collapsible = true, ...res
</>
);
}
export function SidebarTabsDropdown({
options,
placeholder,
...props
}: {
placeholder?: ReactNode;
options: LayoutTab[];
} & ComponentProps<'button'>) {
const [open, setOpen] = useState(false);
const { closeOnRedirect } = useSidebar();
const pathname = usePathname();
const selected = useMemo(() => {
return options.findLast((item) => isLayoutTabActive(item, pathname));
}, [options, pathname]);
const onClick = () => {
closeOnRedirect.current = false;
setOpen(false);
};
const item = selected ? (
<>
<div className="size-9 shrink-0 empty:hidden md:size-5">{selected.icon}</div>
<div>
<p className="text-sm font-medium">{selected.title}</p>
<p className="text-sm text-fd-muted-foreground empty:hidden md:hidden">
{selected.description}
</p>
</div>
</>
) : (
placeholder
);
return (
<Popover open={open} onOpenChange={setOpen}>
{item && (
<PopoverTrigger
{...props}
className={cn(
'flex items-center gap-2 rounded-lg p-2 border bg-fd-secondary/50 text-start text-fd-secondary-foreground transition-colors hover:bg-fd-accent data-[state=open]:bg-fd-accent data-[state=open]:text-fd-accent-foreground',
props.className,
)}
>
{item}
<ChevronsUpDown className="shrink-0 ms-auto size-4 text-fd-muted-foreground" />
</PopoverTrigger>
)}
<PopoverContent className="flex flex-col gap-1 w-(--radix-popover-trigger-width) p-1 fd-scroll-container">
{options.map((item) => {
const isActive = selected && item.url === selected.url;
if (!isActive && item.unlisted) return;
return (
<Link
key={item.url}
href={item.url}
onClick={onClick}
{...item.props}
className={cn(
'flex items-center gap-2 rounded-lg p-1.5 hover:bg-fd-accent hover:text-fd-accent-foreground',
item.props?.className,
)}
>
<div className="shrink-0 size-9 md:mb-auto md:size-5 empty:hidden">{item.icon}</div>
<div>
<p className="text-sm font-medium leading-none">{item.title}</p>
<p className="text-[0.8125rem] text-fd-muted-foreground mt-1 empty:hidden">
{item.description}
</p>
</div>
<Check
className={cn(
'shrink-0 ms-auto size-3.5 text-fd-primary',
!isActive && 'invisible',
)}
/>
</Link>
);
})}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,126 @@
'use client';
import { usePathname } from 'fumadocs-core/framework';
import Link from 'fumadocs-core/link';
import { useI18n } from '@/contexts/i18n';
import type { FC, ComponentProps } from 'react';
import { isLinkItemActive, type BaseLayoutProps, type LinkItemType } from '.';
import {
type LanguageSelectProps,
type LanguageSelectTextProps,
LanguageSelect,
LanguageSelectText,
} from './slots/language-select';
import {
type SearchTriggerProps,
type FullSearchTriggeProps,
SearchTrigger,
FullSearchTrigger,
} from './slots/search-trigger';
import { type ThemeSwitchProps, ThemeSwitch } from './slots/theme-switch';
export function LinkItem({
ref,
item,
...props
}: Omit<ComponentProps<'a'>, 'href'> & { item: Extract<LinkItemType, { url: string }> }) {
const pathname = usePathname();
const active = isLinkItemActive(item, pathname);
return (
<Link ref={ref} href={item.url} external={item.external} {...props} data-active={active}>
{props.children}
</Link>
);
}
export interface BaseSlots {
navTitle?: FC<ComponentProps<'a'>>;
searchTrigger?: {
sm: FC<SearchTriggerProps>;
full: FC<FullSearchTriggeProps>;
};
languageSelect?: {
root: FC<LanguageSelectProps>;
text: FC<LanguageSelectTextProps>;
};
themeSwitch?: FC<ThemeSwitchProps>;
}
export interface BaseSlotsProps<P extends BaseLayoutProps = BaseLayoutProps> extends Pick<
P,
'nav'
> {
themeSwitch: Omit<NonNullable<P['themeSwitch']>, 'enabled'>;
searchToggle: Omit<NonNullable<P['searchToggle']>, 'enabled'>;
}
export function baseSlots({ useProps }: { useProps: () => BaseSlotsProps }) {
function InlineThemeSwitch(props: ThemeSwitchProps) {
const { themeSwitch } = useProps();
if (themeSwitch.component) return themeSwitch.component;
return <ThemeSwitch {...props} {...themeSwitch} />;
}
function InlineSearchTrigger(props: SearchTriggerProps) {
const { searchToggle } = useProps();
if (searchToggle.components?.sm) return searchToggle.components.sm;
return <SearchTrigger {...props} {...searchToggle.sm} />;
}
function InlineSearchTriggerFull(props: FullSearchTriggeProps) {
const { searchToggle } = useProps();
if (searchToggle.components?.lg) return searchToggle.components.lg;
return <FullSearchTrigger {...props} {...searchToggle.full} />;
}
function InlineNavTitle({ href: defaultUrl = '/', ...props }: ComponentProps<'a'>) {
const { url = defaultUrl, title } = useProps().nav ?? {};
if (typeof title === 'function') return title({ href: url, ...props });
return (
<Link href={url} {...props}>
{title}
</Link>
);
}
return {
useProvider(options: BaseLayoutProps): {
baseSlots: BaseSlots;
baseProps: BaseSlotsProps;
} {
const { locales = [] } = useI18n();
const {
nav,
slots = {},
i18n = locales.length > 1,
searchToggle: { enabled: searchToggleEnabled = true, ...searchToggle } = {},
themeSwitch: { enabled: themeSwitchEnabled = true, ...themeSwitch } = {},
} = options;
return {
baseSlots: {
navTitle: slots.navTitle ?? InlineNavTitle,
themeSwitch: themeSwitchEnabled ? (slots.themeSwitch ?? InlineThemeSwitch) : undefined,
languageSelect: i18n
? (slots.languageSelect ?? {
root: LanguageSelect,
text: LanguageSelectText,
})
: undefined,
searchTrigger: searchToggleEnabled
? (slots.searchTrigger ?? {
sm: InlineSearchTrigger,
full: InlineSearchTriggerFull,
})
: undefined,
},
baseProps: {
nav,
searchToggle,
themeSwitch,
},
};
},
};
}
+130 -134
View File
@@ -1,23 +1,10 @@
import { useMemo, type ComponentProps, type ReactNode, type FC } from 'react';
import { useMemo, type ComponentProps, type ReactNode } from 'react';
import type { I18nConfig } from 'fumadocs-core/i18n';
import type { LinkItemType } from '@/utils/link-item';
import {
FullSearchTrigger,
SearchTrigger,
type FullSearchTriggeProps,
type SearchTriggerProps,
} from '../slots/search-toggle';
import { ThemeSwitch, type ThemeSwitchProps } from '../slots/theme-toggle';
import {
LanguageSelect,
LanguageSelectText,
type LanguageSelectProps,
type LanguageSelectTextProps,
} from '../slots/language-toggle';
import { useI18n } from '@/contexts/i18n';
import { NavTitle } from '../slots/nav-title';
import * as PageTree from 'fumadocs-core/page-tree';
import { isActive, normalize } from '@/utils/urls';
import type { BaseSlots } from './client';
import type { ThemeSwitchProps } from './slots/theme-switch';
import type { FullSearchTriggeProps, SearchTriggerProps } from './slots/search-trigger';
export interface NavOptions {
enabled?: boolean;
@@ -55,22 +42,8 @@ export interface BaseLayoutProps {
nav?: NavOptions;
slots?: BaseSlots;
children?: ReactNode;
themeSwitch?: {
enabled?: boolean;
mode?: 'light-dark' | 'light-dark-system';
/** @deprecated use `slots.themeSwitch` instead */
component?: ReactNode;
};
searchToggle?: {
enabled?: boolean;
/** @deprecated use `slots.searchTrigger` instead */
components?: {
sm?: ReactNode;
lg?: ReactNode;
};
};
themeSwitch?: ThemeSwitchOptions;
searchToggle?: SearchToggleOptions;
/**
* @deprecated this is now optional for i18n setups, you can still customise language switch from `slots`.
@@ -78,95 +51,21 @@ export interface BaseLayoutProps {
i18n?: boolean | I18nConfig;
}
export interface BaseSlots {
navTitle?: FC<ComponentProps<'a'>>;
searchTrigger?: {
sm: FC<SearchTriggerProps>;
full: FC<FullSearchTriggeProps>;
interface SearchToggleOptions {
enabled?: boolean;
sm?: SearchTriggerProps;
full?: FullSearchTriggeProps;
/** @deprecated use `slots.searchTrigger` instead */
components?: {
sm?: ReactNode;
lg?: ReactNode;
};
languageSelect?: {
root: FC<LanguageSelectProps>;
text: FC<LanguageSelectTextProps>;
};
themeSwitch?: FC<ThemeSwitchProps>;
}
export type BaseSlotsProps<P extends BaseLayoutProps = BaseLayoutProps> = Pick<
P,
'themeSwitch' | 'searchToggle' | 'nav'
>;
export function baseSlots({ useProps }: { useProps: () => BaseSlotsProps }) {
function InlineThemeSwitch(props: ThemeSwitchProps) {
const { mode = props.mode, component } = useProps().themeSwitch ?? {};
if (component) return component;
return <ThemeSwitch {...props} mode={mode} />;
}
function InlineSearchTrigger(props: SearchTriggerProps) {
const { components } = useProps().searchToggle ?? {};
if (components?.sm) return components.sm;
return <SearchTrigger {...props} />;
}
function InlineSearchTriggerFull(props: FullSearchTriggeProps) {
const { components } = useProps().searchToggle ?? {};
if (components?.lg) return components.lg;
return <FullSearchTrigger {...props} />;
}
function InlineNavTitle(props: ComponentProps<'a'>) {
const { url = props.href ?? '/', title } = useProps().nav ?? {};
if (typeof title === 'function') return title(props);
return (
<NavTitle {...props} href={url}>
{title}
</NavTitle>
);
}
return {
useProvider(options: BaseLayoutProps): {
baseSlots: BaseSlots;
baseProps: BaseSlotsProps;
} {
const { locales = [] } = useI18n();
const {
nav,
slots = {},
i18n = locales.length > 1,
searchToggle = {},
themeSwitch = {},
} = options;
return {
baseSlots: {
navTitle: slots.navTitle ?? InlineNavTitle,
themeSwitch:
themeSwitch.enabled !== false ? (slots.themeSwitch ?? InlineThemeSwitch) : undefined,
languageSelect: i18n
? (slots.languageSelect ?? {
root: LanguageSelect,
text: LanguageSelectText,
})
: undefined,
searchTrigger:
searchToggle.enabled !== false
? (slots.searchTrigger ?? {
sm: InlineSearchTrigger,
full: InlineSearchTriggerFull,
})
: undefined,
},
baseProps: {
nav,
searchToggle,
themeSwitch,
},
};
},
};
interface ThemeSwitchOptions extends ThemeSwitchProps {
enabled?: boolean;
/** @deprecated use `slots.themeSwitch` instead */
component?: ReactNode;
}
export interface LayoutTab {
@@ -244,6 +143,113 @@ export function getLayoutTabs(
return results;
}
export function isLayoutTabActive(tab: LayoutTab, pathname: string) {
if (tab.$folder) {
return (
PageTree.findPath(
tab.$folder.children,
(node) => node.type === 'page' && isActive(node.url, pathname),
) !== null
);
}
if (tab.urls) {
return tab.urls.has(normalize(pathname));
}
return isActive(tab.url, pathname, true);
}
interface Filterable {
/**
* Restrict where the item is displayed
*
* @defaultValue 'all'
*/
on?: 'menu' | 'nav' | 'all';
}
interface WithHref {
url: string;
/**
* When the item is marked as active
*
* @defaultValue 'url'
*/
active?: 'url' | 'nested-url' | 'none';
external?: boolean;
}
export interface MainItemType extends WithHref, Filterable {
type?: 'main';
icon?: ReactNode;
text: ReactNode;
description?: ReactNode;
}
export interface IconItemType extends WithHref, Filterable {
type: 'icon';
/**
* `aria-label` of icon button
*/
label?: string;
icon: ReactNode;
text: ReactNode;
/**
* @defaultValue true
*/
secondary?: boolean;
}
export interface ButtonItemType extends WithHref, Filterable {
type: 'button';
icon?: ReactNode;
text: ReactNode;
/**
* @defaultValue false
*/
secondary?: boolean;
}
export interface MenuItemType extends Partial<WithHref>, Filterable {
type: 'menu';
icon?: ReactNode;
text: ReactNode;
items: (
| (MainItemType & {
/**
* Options when displayed on navigation menu
*/
menu?: ComponentProps<'a'> & {
banner?: ReactNode;
};
})
| CustomItemType
)[];
/**
* @defaultValue false
*/
secondary?: boolean;
}
export interface CustomItemType extends Filterable {
type: 'custom';
/**
* @defaultValue false
*/
secondary?: boolean;
children: ReactNode;
}
export type LinkItemType =
| MainItemType
| IconItemType
| ButtonItemType
| MenuItemType
| CustomItemType;
/**
* Get link items with shortcuts
*/
@@ -294,21 +300,11 @@ export function useLinkItems({ githubUrl, links }: Pick<BaseLayoutProps, 'links'
}, [links, githubUrl]);
}
export function isLayoutTabActive(tab: LayoutTab, pathname: string) {
if (tab.$folder) {
return (
PageTree.findPath(
tab.$folder.children,
(node) => node.type === 'page' && isActive(node.url, pathname),
) !== null
);
}
export function isLinkItemActive(link: LinkItemType, pathname: string) {
if (link.type === 'custom' || !link.url) return false;
if (link.active === 'none') return false;
if (tab.urls) {
return tab.urls.has(normalize(pathname));
}
return isActive(tab.url, pathname, true);
return isActive(link.url, pathname, link.active === 'nested-url');
}
export type * from '@/utils/link-item';
export { type BaseSlots, type BaseSlotsProps, baseSlots, LinkItem } from './client';
@@ -2,7 +2,7 @@
import { cva } from 'class-variance-authority';
import { Airplay, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { ComponentProps, useEffect, useState } from 'react';
import { type ComponentProps, useEffect, useState } from 'react';
import { cn } from '@/utils/cn';
const itemVariants = cva('size-6.5 p-1.5 text-fd-muted-foreground', {
@@ -1,7 +0,0 @@
'use client';
import Link from 'fumadocs-core/link';
import type { ComponentProps } from 'react';
export function NavTitle({ href, ...props }: ComponentProps<'a'>) {
return <Link href={href ?? ''} {...props} />;
}
-118
View File
@@ -1,118 +0,0 @@
'use client';
import type { ComponentProps, ReactNode } from 'react';
import { usePathname } from 'fumadocs-core/framework';
import { isActive } from './urls';
import Link from 'fumadocs-core/link';
interface Filterable {
/**
* Restrict where the item is displayed
*
* @defaultValue 'all'
*/
on?: 'menu' | 'nav' | 'all';
}
interface WithHref {
url: string;
/**
* When the item is marked as active
*
* @defaultValue 'url'
*/
active?: 'url' | 'nested-url' | 'none';
external?: boolean;
}
export interface MainItemType extends WithHref, Filterable {
type?: 'main';
icon?: ReactNode;
text: ReactNode;
description?: ReactNode;
}
export interface IconItemType extends WithHref, Filterable {
type: 'icon';
/**
* `aria-label` of icon button
*/
label?: string;
icon: ReactNode;
text: ReactNode;
/**
* @defaultValue true
*/
secondary?: boolean;
}
export interface ButtonItemType extends WithHref, Filterable {
type: 'button';
icon?: ReactNode;
text: ReactNode;
/**
* @defaultValue false
*/
secondary?: boolean;
}
export interface MenuItemType extends Partial<WithHref>, Filterable {
type: 'menu';
icon?: ReactNode;
text: ReactNode;
items: (
| (MainItemType & {
/**
* Options when displayed on navigation menu
*/
menu?: ComponentProps<'a'> & {
banner?: ReactNode;
};
})
| CustomItemType
)[];
/**
* @defaultValue false
*/
secondary?: boolean;
}
export interface CustomItemType extends Filterable {
type: 'custom';
/**
* @defaultValue false
*/
secondary?: boolean;
children: ReactNode;
}
export type LinkItemType =
| MainItemType
| IconItemType
| ButtonItemType
| MenuItemType
| CustomItemType;
export function LinkItem({
ref,
item,
...props
}: Omit<ComponentProps<'a'>, 'href'> & { item: LinkItemType & WithHref }) {
const active = useLinkItemActive(item);
return (
<Link ref={ref} href={item.url} external={item.external} {...props} data-active={active}>
{props.children}
</Link>
);
}
export function useLinkItemActive(link: LinkItemType) {
const pathname = usePathname();
if (link.type === 'custom' || !link.url) return false;
if (link.active === 'none') return false;
return isActive(link.url, pathname, link.active === 'nested-url');
}
-31
View File
@@ -1,31 +0,0 @@
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) {}
}
+24 -3
View File
@@ -8,11 +8,11 @@ export default defineConfig({
entry: [
'./src/*.{ts,tsx}',
'./src/{components,contexts,provider,tailwind,og}/**/*.{ts,tsx}',
'./src/layouts/*/{index,client}.tsx',
'./src/layouts/*/page/{index,client}.tsx',
'./src/layouts/*/index.tsx',
'./src/layouts/*/page/index.tsx',
'./src/layouts/**/slots/*',
'./src/layouts/home/{navbar,not-found}.tsx',
'./src/utils/use-*.{ts,tsx}',
'./src/utils/renderer.ts',
],
fixedExtension: false,
unbundle: true,
@@ -32,4 +32,25 @@ export default defineConfig({
deps: {
onlyBundle: [],
},
exports: {
exclude: ['mdx.server', 'tailwind/typography'],
customExports: {
'./style.css': './dist/style.css',
'./css/*': './css/*',
'./image-zoom.css': './dist/image-zoom.css',
'./utils/get-sidebar-tabs': {
types: './dist/components/sidebar/tabs/index.d.ts',
import: './dist/components/sidebar/tabs/index.js',
},
'./components/tabs.unstyled': {
types: './dist/components/ui/tabs.d.ts',
import: './dist/components/ui/tabs.js',
},
'./mdx': {
types: './dist/mdx.d.ts',
node: './dist/mdx.server.js',
import: './dist/mdx.js',
},
},
},
});
+3 -14
View File
@@ -49,7 +49,7 @@ export function resolveExternal(
if (ref.type === 'file') {
const file = path.relative(toRegistryDir, ref.file).replaceAll(path.sep, '/');
if (file === 'utils/renderer.ts' || /^contexts\//.test(file) || /^utils\/use-/.test(file)) {
if (/^contexts\//.test(file) || /^utils\/use-/.test(file)) {
return {
dep: toPackageName,
type: 'dependency',
@@ -62,9 +62,9 @@ export function resolveExternal(
export async function findSlotComponents(dir: string): Promise<Component[]> {
const slots: Component[] = [];
for await (const file of glob('**/slots/*.tsx', { cwd: dir })) {
for await (const file of glob('layouts/**/slots/*', { cwd: dir })) {
const name = path.relative('layouts', file);
if (name.startsWith('..')) continue;
slots.push({
name: name.slice(0, -path.extname(name).length),
unlisted: true,
@@ -112,17 +112,6 @@ export const commonComponents: Component[] = [
},
],
},
{
name: 'link-item',
unlisted: true,
files: [
{
type: 'components',
path: 'utils/link-item.tsx',
target: '<dir>/layout/link-item.tsx',
},
],
},
{
name: 'toc',
unlisted: true,
+2 -2
View File
@@ -2,7 +2,7 @@
"extends": "tsconfig/base.json",
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"target": "esnext"
},
"exclude": ["node_modules", "dist", "eslint.config.mjs"],
"exclude": ["node_modules", "dist", "eslint.config.mjs"]
}