UI: Support searchToggle option to customise search toggle

This commit is contained in:
Fuma Nama
2025-04-10 22:07:45 +08:00
parent ff12b53268
commit eb18da9c64
7 changed files with 175 additions and 137 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Support `searchToggle` option to customise search toggle
+33 -35
View File
@@ -12,7 +12,7 @@ import {
SidebarViewport,
SidebarPageTree,
} from '@/components/layout/sidebar';
import { replaceOrDefault } from '@/layouts/shared';
import { slot, slots } from '@/layouts/shared';
import {
type LinkItemType,
BaseLinkItem,
@@ -63,23 +63,24 @@ export interface DocsLayoutProps extends BaseLayoutProps {
}
export function DocsLayout({
nav: {
enabled: navEnabled = true,
component: navReplace,
transparentMode,
...nav
} = {},
themeSwitch,
sidebar = {},
nav: { transparentMode, ...nav } = {},
searchToggle,
disableThemeSwitch = false,
themeSwitch = { enabled: !disableThemeSwitch },
sidebar: { tabs: tabOptions, ...sidebar } = {},
i18n = false,
children,
...props
}: DocsLayoutProps): ReactNode {
const tabs = useMemo(
() => getSidebarTabsFromOptions(tabOptions, props.tree) ?? [],
[tabOptions, props.tree],
);
const links = getLinks(props.links ?? [], props.githubUrl);
const variables = cn(
'[--fd-tocnav-height:36px] md:[--fd-sidebar-width:268px] lg:[--fd-sidebar-width:286px] xl:[--fd-toc-width:286px] xl:[--fd-tocnav-height:0px]',
!navReplace && navEnabled
!nav.component && nav.enabled !== false
? '[--fd-nav-height:calc(var(--spacing)*14)] md:[--fd-nav-height:0px]'
: undefined,
);
@@ -92,8 +93,8 @@ export function DocsLayout({
return (
<TreeContextProvider tree={props.tree}>
<NavProvider transparentMode={transparentMode}>
{replaceOrDefault(
{ enabled: navEnabled, component: navReplace },
{slot(
nav,
<Navbar className="md:hidden">
<Link
href={nav.url ?? '/'}
@@ -104,10 +105,9 @@ export function DocsLayout({
<div className="flex flex-1 flex-row items-center gap-1">
{nav.children}
</div>
<SearchToggle hideIfDisabled />
{slots('sm', searchToggle, <SearchToggle hideIfDisabled />)}
<NavbarSidebarTrigger className="-me-2 md:hidden" />
</Navbar>,
nav,
)}
<main
id="nd-docs-layout"
@@ -122,10 +122,9 @@ export function DocsLayout({
...props.containerProps?.style,
}}
>
{replaceOrDefault(
{slot(
sidebar,
<DocsLayoutSidebar
tree={props.tree}
{...sidebar}
links={links}
nav={
@@ -139,6 +138,22 @@ export function DocsLayout({
{nav.children}
</>
}
banner={
<>
{tabs.length > 0 ? (
<RootToggle options={tabs} className="-mx-2" />
) : null}
{slots(
'lg',
searchToggle,
<LargeSearchToggle
hideIfDisabled
className="rounded-lg max-md:hidden"
/>,
)}
{sidebar.banner}
</>
}
footer={
<>
<DocsLayoutSidebarFooter
@@ -161,23 +176,15 @@ export function DocsLayout({
export function DocsLayoutSidebar({
collapsible = true,
components,
tabs: tabOptions,
hideSearch,
tree,
nav,
links = [],
footer,
banner,
...props
}: Partial<SidebarOptions> & {
tree: PageTree.Root;
}: Omit<SidebarOptions, 'tabs'> & {
links?: LinkItemType[];
nav?: ReactNode;
}) {
const tabs = useMemo(
() => getSidebarTabsFromOptions(tabOptions, tree) ?? [],
[tabOptions, tree],
);
const Aside = collapsible ? CollapsibleSidebar : Sidebar;
return (
@@ -205,15 +212,6 @@ export function DocsLayoutSidebar({
)}
</div>
{banner}
{tabs.length > 0 ? (
<RootToggle options={tabs} className="-mx-2" />
) : null}
{!hideSearch ? (
<LargeSearchToggle
hideIfDisabled
className="rounded-lg max-md:hidden"
/>
) : null}
</SidebarHeader>
<SidebarViewport>
<div className="mb-4 empty:hidden">
@@ -266,7 +264,7 @@ export function DocsLayoutSidebarFooter({
<LanguageToggleText className="md:hidden" />
</LanguageToggle>
) : null}
{replaceOrDefault(
{slot(
themeSwitch,
<ThemeToggle className="p-0" mode={themeSwitch?.mode} />,
)}
-7
View File
@@ -31,13 +31,6 @@ export interface SidebarOptions extends SidebarProps {
banner?: ReactNode;
footer?: ReactNode;
/**
* Hide search trigger. You can also disable search for the entire site from `<RootProvider />`.
*
* @defaultValue false
*/
hideSearch?: boolean;
}
export function SidebarLinkItem({
+47 -34
View File
@@ -1,5 +1,5 @@
import { Fragment, type HTMLAttributes } from 'react';
import { type NavOptions, replaceOrDefault } from '@/layouts/shared';
import { Fragment, type HTMLAttributes, useMemo } from 'react';
import { type NavOptions, slot, slots } from '@/layouts/shared';
import { cn } from '@/utils/cn';
import { getLinks, type BaseLayoutProps } from './shared';
import { NavProvider } from '@/contexts/layout';
@@ -30,9 +30,7 @@ import {
MenuTrigger,
} from '@/layouts/home/menu';
export interface HomeLayoutProps
extends BaseLayoutProps,
HTMLAttributes<HTMLElement> {
export interface HomeLayoutProps extends BaseLayoutProps {
nav?: Partial<
NavOptions & {
/**
@@ -43,19 +41,20 @@ export interface HomeLayoutProps
>;
}
export function HomeLayout(props: HomeLayoutProps) {
export function HomeLayout(
props: HomeLayoutProps & HTMLAttributes<HTMLElement>,
) {
const {
nav,
links,
githubUrl,
i18n: _i18n,
themeSwitch: _themeSwitch,
disableThemeSwitch: _disableThemeSwitch,
i18n,
disableThemeSwitch = false,
themeSwitch = { enabled: !disableThemeSwitch },
searchToggle,
...rest
} = props;
const finalLinks = getLinks(links, githubUrl);
return (
<NavProvider transparentMode={nav?.transparentMode}>
<main
@@ -63,23 +62,36 @@ export function HomeLayout(props: HomeLayoutProps) {
{...rest}
className={cn('flex flex-1 flex-col pt-14', rest.className)}
>
{replaceOrDefault(nav, <Header finalLinks={finalLinks} {...props} />, {
items: finalLinks,
})}
{slot(
nav,
<Header
links={links}
nav={nav}
themeSwitch={themeSwitch}
searchToggle={searchToggle}
i18n={i18n}
githubUrl={githubUrl}
/>,
)}
{props.children}
</main>
</NavProvider>
);
}
function Header({
nav: { enableSearch = true, ...nav } = {},
export function Header({
nav = {},
i18n = false,
finalLinks,
links,
githubUrl,
themeSwitch,
}: HomeLayoutProps & {
finalLinks: LinkItemType[];
}) {
searchToggle,
}: HomeLayoutProps) {
const finalLinks = useMemo(
() => getLinks(links, githubUrl),
[links, githubUrl],
);
const navItems = finalLinks.filter((item) =>
['nav', 'all'].includes(item.on ?? 'all'),
);
@@ -104,16 +116,20 @@ function Header({
))}
</ul>
<div className="flex flex-row items-center justify-end gap-1.5 flex-1">
{enableSearch ? (
<>
<SearchToggle className="lg:hidden" hideIfDisabled />
<LargeSearchToggle
className="w-full max-w-[240px] max-lg:hidden"
hideIfDisabled
/>
</>
) : null}
{replaceOrDefault(
{slots(
'sm',
searchToggle,
<SearchToggle className="lg:hidden" hideIfDisabled />,
)}
{slots(
'lg',
searchToggle,
<LargeSearchToggle
className="w-full max-w-[240px] max-lg:hidden"
hideIfDisabled
/>,
)}
{slot(
themeSwitch,
<ThemeToggle className="max-lg:hidden" mode={themeSwitch?.mode} />,
)}
@@ -157,10 +173,7 @@ function Header({
<ChevronDown className="size-3 text-fd-muted-foreground" />
</LanguageToggle>
) : null}
{replaceOrDefault(
themeSwitch,
<ThemeToggle mode={themeSwitch?.mode} />,
)}
{slot(themeSwitch, <ThemeToggle mode={themeSwitch?.mode} />)}
</div>
</MenuContent>
</Menu>
+59 -43
View File
@@ -1,9 +1,5 @@
import { Fragment, type HTMLAttributes, useMemo } from 'react';
import {
type BaseLayoutProps,
getLinks,
replaceOrDefault,
} from '@/layouts/shared';
import { Fragment, type HTMLAttributes, type ReactNode, useMemo } from 'react';
import { type BaseLayoutProps, getLinks, slot, slots } from '@/layouts/shared';
import {
CollapsibleSidebar,
Sidebar,
@@ -14,10 +10,6 @@ import {
SidebarPageTree,
} from '@/components/layout/sidebar';
import { TreeContextProvider } from '@/contexts/tree';
import {
LargeSearchToggle,
SearchToggle,
} from '@/components/layout/search-toggle';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { ChevronDown, Languages, SidebarIcon } from 'lucide-react';
@@ -50,6 +42,10 @@ import {
} from '@/contexts/layout';
import { type Option, RootToggle } from '@/components/layout/root-toggle';
import Link from 'fumadocs-core/link';
import {
LargeSearchToggle,
SearchToggle,
} from '@/components/layout/search-toggle';
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
@@ -76,7 +72,9 @@ export function DocsLayout({
...sidebar
} = {},
i18n = false,
themeSwitch,
disableThemeSwitch = false,
themeSwitch = { enabled: !disableThemeSwitch },
searchToggle,
...props
}: DocsLayoutProps) {
const navMode = nav.mode ?? 'auto';
@@ -189,7 +187,7 @@ export function DocsLayout({
<Languages className="size-5 text-fd-muted-foreground" />
</LanguageToggle>
) : null}
{replaceOrDefault(
{slot(
themeSwitch,
<ThemeToggle
className="md:hidden"
@@ -200,12 +198,26 @@ export function DocsLayout({
</SidebarFooter>
</Aside>
<DocsNavbar
nav={nav}
mode={nav.mode}
nav={
<Link
href={nav.url ?? '/'}
className={cn(
'inline-flex items-center gap-2.5 font-semibold',
navMode === 'auto' && 'md:hidden',
)}
>
{nav.title}
</Link>
}
links={links}
i18n={i18n}
sidebarCollapsible={sidebarCollapsible}
searchToggle={searchToggle}
tabs={tabMode == 'navbar' ? tabs : []}
/>
>
{nav.children}
</DocsNavbar>
<StylesProvider {...pageStyles}>{props.children}</StylesProvider>
</main>
</NavProvider>
@@ -214,22 +226,28 @@ export function DocsLayout({
}
function DocsNavbar({
sidebarCollapsible,
mode: navMode = 'auto',
nav,
sidebarCollapsible = false,
links,
themeSwitch,
nav = {},
i18n,
searchToggle,
i18n = false,
tabs,
children,
}: {
nav: DocsLayoutProps['nav'];
sidebarCollapsible: boolean;
i18n: Required<DocsLayoutProps>['i18n'];
mode?: 'top' | 'auto';
nav?: ReactNode;
sidebarCollapsible?: boolean;
i18n?: DocsLayoutProps['i18n'];
themeSwitch?: DocsLayoutProps['themeSwitch'];
searchToggle?: DocsLayoutProps['searchToggle'];
links: LinkItemType[];
tabs: Option[];
children?: ReactNode;
}) {
const navMode = nav.mode ?? 'auto';
return (
<Navbar mode={navMode}>
<div
@@ -257,25 +275,19 @@ function DocsNavbar({
<SidebarIcon />
</SidebarCollapseTrigger>
) : null}
<Link
href={nav.url ?? '/'}
className={cn(
'inline-flex items-center gap-2.5 font-semibold',
navMode === 'auto' && 'md:hidden',
)}
>
{nav.title}
</Link>
{nav}
</div>
<LargeSearchToggle
hideIfDisabled
className={cn(
'w-full my-auto rounded-xl max-md:hidden',
navMode === 'top' ? 'max-w-sm px-2' : 'max-w-[240px]',
)}
/>
{slots(
'lg',
searchToggle,
<LargeSearchToggle
hideIfDisabled
className={cn(
'w-full my-auto rounded-xl max-md:hidden',
navMode === 'top' ? 'max-w-sm px-2' : 'max-w-[240px]',
)}
/>,
)}
<div className="flex flex-1 flex-row items-center justify-end">
<div className="flex flex-row items-center gap-6 px-4 empty:hidden max-lg:hidden">
{links
@@ -288,8 +300,12 @@ function DocsNavbar({
/>
))}
</div>
{nav.children}
<SearchToggle hideIfDisabled className="md:hidden" />
{children}
{slots(
'sm',
searchToggle,
<SearchToggle hideIfDisabled className="md:hidden" />,
)}
<NavbarSidebarTrigger className="md:hidden" />
{links
.filter((item) => item.type === 'icon')
@@ -311,7 +327,7 @@ function DocsNavbar({
<Languages className="size-4.5 text-fd-muted-foreground" />
</LanguageToggle>
) : null}
{replaceOrDefault(
{slot(
themeSwitch,
<ThemeToggle
className="ms-2 max-md:hidden"
+26 -7
View File
@@ -16,12 +16,6 @@ export interface NavOptions extends NavProviderProps {
*/
url?: string;
/**
* Show/hide search toggle
*
* Note: Enable/disable search from root provider instead
*/
enableSearch?: boolean;
children?: ReactNode;
}
@@ -32,6 +26,14 @@ export interface BaseLayoutProps {
mode?: 'light-dark' | 'light-dark-system';
};
searchToggle?: Partial<{
enabled: boolean;
components: Partial<{
sm: ReactNode;
lg: ReactNode;
}>;
}>;
/**
* Remove theme switcher component
*
@@ -91,7 +93,7 @@ export function getLinks(
return result;
}
export function replaceOrDefault(
export function slot(
obj:
| {
enabled?: boolean;
@@ -108,3 +110,20 @@ export function replaceOrDefault(
return def;
}
export function slots<Comp extends Record<string, ReactNode>>(
variant: keyof Comp,
obj:
| {
enabled?: boolean;
components?: Comp;
}
| undefined,
def: ReactNode,
): ReactNode {
if (obj?.enabled === false) return;
if (obj?.components?.[variant] !== undefined)
return <Slot>{obj.components[variant]}</Slot>;
return def;
}
+5 -11
View File
@@ -6,7 +6,7 @@ import {
type ReactNode,
} from 'react';
import { type AnchorProviderProps, AnchorProvider } from 'fumadocs-core/toc';
import { replaceOrDefault } from '@/layouts/shared';
import { slot } from '@/layouts/shared';
import { cn } from './utils/cn';
import {
Footer,
@@ -151,7 +151,7 @@ export function DocsPage({
} as object
}
>
{replaceOrDefault(
{slot(
{ enabled: tocPopoverEnabled, component: tocPopoverReplace },
<TocPopoverHeader className="h-10">
<TocPopoverTrigger className="w-full" items={toc} />
@@ -179,10 +179,7 @@ export function DocsPage({
props.article?.className,
)}
>
{replaceOrDefault(
props.breadcrumb,
<Breadcrumb {...props.breadcrumb} />,
)}
{slot(props.breadcrumb, <Breadcrumb {...props.breadcrumb} />)}
{props.children}
<div role="none" className="flex-1" />
<div className="flex flex-row flex-wrap items-center justify-between gap-4 empty:hidden">
@@ -193,13 +190,10 @@ export function DocsPage({
<LastUpdate date={new Date(props.lastUpdate)} />
) : null}
</div>
{replaceOrDefault(
props.footer,
<Footer items={props.footer?.items} />,
)}
{slot(props.footer, <Footer items={props.footer?.items} />)}
</PageArticle>
</PageBody>
{replaceOrDefault(
{slot(
{ enabled: tocEnabled, component: tocReplace },
<Toc>
{tocOptions.header}