UI: Support custom nav mode and tabs mode on Notebook layout

This commit is contained in:
Fuma Nama
2025-02-15 20:29:44 +08:00
parent e7bdce90b2
commit 8f5993b008
5 changed files with 220 additions and 177 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Support custom nav mode and tabs mode on Notebook layout
+1 -66
View File
@@ -1,24 +1,13 @@
'use client';
import { ChevronDown, Menu, X } from 'lucide-react';
import { Menu, X } from 'lucide-react';
import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react';
import { cn } from '@/utils/cn';
import { BaseLinkItem, type LinkItemType } from '@/layouts/links';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cva } from 'class-variance-authority';
import { buttonVariants } from '@/components/ui/button';
import { useSidebar } from '@/contexts/sidebar';
import { useNav } from '@/components/layout/nav';
import { SidebarTrigger } from 'fumadocs-core/sidebar';
const itemVariants = cva(
'flex flex-row items-center gap-2 rounded-md px-3 py-2.5 text-fd-muted-foreground transition-colors duration-100 [overflow-wrap:anywhere] hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none md:px-2 md:py-1.5 [&_svg]:size-4',
);
export function Navbar(props: HTMLAttributes<HTMLElement>) {
const { open } = useSidebar();
const { isTransparent } = useNav();
@@ -58,57 +47,3 @@ export function NavbarSidebarTrigger(
</SidebarTrigger>
);
}
interface MenuItemProps extends HTMLAttributes<HTMLElement> {
item: LinkItemType;
}
function MenuItem({ item, ...props }: MenuItemProps) {
if (item.type === 'custom')
return (
<div {...props} className={cn('grid', props.className)}>
{item.children}
</div>
);
if (item.type === 'menu') {
return (
<Collapsible className="flex flex-col">
<CollapsibleTrigger
{...props}
className={cn(itemVariants(), 'group', props.className)}
>
{item.icon}
{item.text}
<ChevronDown className="ms-auto transition-transform group-data-[state=closed]:-rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col py-2 ps-2">
{item.items.map((child, i) => (
<MenuItem key={i} item={child} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}
return (
<BaseLinkItem
item={item}
{...props}
className={cn(
item.type === 'button'
? buttonVariants({
color: 'secondary',
className: 'gap-1.5 [&_svg]:size-4',
})
: itemVariants(),
props.className,
)}
>
{item.icon}
{item.text}
</BaseLinkItem>
);
}
+1 -1
View File
@@ -75,7 +75,7 @@ export function DocsLayout({
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
? '[--fd-nav-height:3.5rem] md:[--fd-nav-height:0px]'
? '[--fd-nav-height:calc(var(--spacing)*14)] md:[--fd-nav-height:0px]'
: undefined,
);
+75 -5
View File
@@ -1,11 +1,15 @@
'use client';
import { cn } from '@/utils/cn';
import type { ButtonHTMLAttributes, HTMLAttributes } from 'react';
import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react';
import { useSidebar } from '@/contexts/sidebar';
import { useNav } from '@/components/layout/nav';
import { SidebarTrigger } from 'fumadocs-core/sidebar';
import { buttonVariants } from '@/components/ui/button';
import { Menu, X } from 'lucide-react';
import Link from 'fumadocs-core/link';
import { usePathname } from 'next/navigation';
import { isActive } from '@/utils/is-active';
import type { Option } from '@/components/layout/root-toggle';
export function Navbar(props: HTMLAttributes<HTMLElement>) {
const { open, collapsed } = useSidebar();
@@ -23,14 +27,13 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
style={
{
paddingInlineStart: collapsed
? 'calc(var(--fd-layout-offset))'
? 'var(--fd-layout-offset)'
: 'calc(var(--fd-layout-offset) + var(--fd-sidebar-width))',
...props.style,
} as object
}
>
<div className="mx-auto flex size-full flex-row items-center border-b border-fd-foreground/10 px-4 md:gap-1.5">
{props.children}
</div>
{props.children}
</header>
);
}
@@ -55,3 +58,70 @@ export function NavbarSidebarTrigger(
</SidebarTrigger>
);
}
export function LayoutTabs(props: HTMLAttributes<HTMLElement>) {
return (
<div
{...props}
className={cn(
'flex flex-row items-end gap-6 overflow-auto',
props.className,
)}
>
{props.children}
</div>
);
}
function useIsSelected(item: Option) {
const pathname = usePathname();
return item.urls
? item.urls.has(pathname.endsWith('/') ? pathname.slice(0, -1) : pathname)
: isActive(item.url, pathname, true);
}
export function LayoutTab(item: Option) {
const { closeOnRedirect } = useSidebar();
const selected = useIsSelected(item);
return (
<Link
className={cn(
'inline-flex items-center py-2.5 border-b-2 border-transparent gap-2 text-fd-muted-foreground text-sm text-nowrap',
selected && 'text-fd-foreground font-medium border-fd-primary',
)}
href={item.url}
onClick={() => {
closeOnRedirect.current = false;
}}
>
{item.title}
</Link>
);
}
export function SidebarLayoutTab({
item,
...props
}: { item: Option } & HTMLAttributes<HTMLElement>) {
const selected = useIsSelected(item);
return (
<Link
{...props}
className={cn(
'flex flex-row items-center px-2 py-1.5 gap-2.5 text-fd-muted-foreground [&_svg]:!size-4.5',
selected
? 'text-fd-primary font-medium'
: 'hover:text-fd-accent-foreground',
props.className,
)}
data-active={selected}
href={item.url}
>
{item.icon}
{item.title}
</Link>
);
}
+138 -105
View File
@@ -1,9 +1,5 @@
import { Fragment, type HTMLAttributes, type ReactNode } from 'react';
import {
type BaseLayoutProps,
getLinks,
type SharedNavProps,
} from '@/layouts/shared';
import { Fragment, type HTMLAttributes } from 'react';
import { type BaseLayoutProps, getLinks } from '@/layouts/shared';
import {
CollapsibleSidebar,
Sidebar,
@@ -13,7 +9,6 @@ import {
SidebarViewport,
SidebarPageTree,
} from '@/layouts/docs/sidebar';
import { RootToggle } from '@/components/layout/root-toggle';
import { TreeContextProvider } from '@/contexts/tree';
import { NavProvider, Title } from '@/components/layout/nav';
import {
@@ -40,11 +35,23 @@ import {
type SidebarOptions,
} from '@/layouts/docs/shared';
import type { PageTree } from 'fumadocs-core/server';
import { Navbar, NavbarSidebarTrigger } from './notebook.client';
import {
LayoutTab,
LayoutTabs,
Navbar,
NavbarSidebarTrigger,
SidebarLayoutTab,
} from './notebook.client';
import { type PageStyles, StylesProvider } from '@/contexts/layout';
import { type Option, RootToggle } from '@/components/layout/root-toggle';
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
tabMode?: 'sidebar' | 'navbar';
nav?: BaseLayoutProps['nav'] & {
mode?: 'top' | 'auto';
};
sidebar?: Omit<Partial<SidebarOptions>, 'component' | 'enabled'>;
@@ -52,6 +59,7 @@ export interface DocsLayoutProps extends BaseLayoutProps {
}
export function DocsLayout({
tabMode = 'sidebar',
nav: { transparentMode, ...nav } = {},
sidebar: {
collapsible: sidebarCollapsible = true,
@@ -63,14 +71,18 @@ export function DocsLayout({
} = {},
i18n = false,
...props
}: DocsLayoutProps): ReactNode {
}: DocsLayoutProps) {
checkPageTree(props.tree);
const navMode = nav.mode ?? 'auto';
const links = getLinks(props.links ?? [], props.githubUrl);
const Aside = sidebarCollapsible ? CollapsibleSidebar : Sidebar;
const tabs = getSidebarTabsFromOptions(tabOptions, props.tree) ?? [];
const variables = cn(
'[--fd-nav-height:3.5rem] [--fd-tocnav-height:36px] md:[--fd-sidebar-width:268px] xl:[--fd-toc-width:268px] xl:[--fd-tocnav-height:0px]',
'[--fd-nav-height:calc(var(--spacing)*14)] [--fd-tocnav-height:36px] md:[--fd-sidebar-width:286px] xl:[--fd-toc-width:286px] xl:[--fd-tocnav-height:0px]',
tabs.length > 0 &&
tabMode === 'navbar' &&
'lg:[--fd-nav-height:calc(var(--spacing)*26)]',
);
const pageStyles: PageStyles = {
@@ -98,36 +110,45 @@ export function DocsLayout({
<Aside
{...sidebar}
className={cn(
'md:ps-(--fd-layout-offset) md:[--fd-nav-height:0px]',
'md:ps-(--fd-layout-offset)',
navMode === 'top'
? 'bg-transparent *:!pt-0'
: 'md:[--fd-nav-height:0px]',
sidebar.className,
)}
>
<SidebarHeader>
<SidebarHeaderItems nav={nav} links={links}>
{nav.children}
{sidebarCollapsible ? (
<SidebarCollapseTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon',
}),
'ms-auto text-fd-muted-foreground',
)}
/>
) : null}
</SidebarHeaderItems>
{nav.title && navMode === 'auto' ? (
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 py-1 font-medium max-md:hidden"
>
{nav.title}
</Link>
) : null}
{nav.children}
{sidebarBanner}
{tabs.length > 0 ? (
{tabMode === 'sidebar' && tabs.length > 0 ? (
<RootToggle options={tabs} className="-mx-2" />
) : null}
</SidebarHeader>
<SidebarViewport>
<div className="mb-4 empty:hidden lg:hidden">
{links.map((item, i) => (
<SidebarLinkItem key={i} item={item} />
{tabMode === 'navbar' &&
tabs.map((tab, i) => (
<SidebarLayoutTab
key={tab.url}
item={tab}
className={cn('lg:hidden', i === tabs.length - 1 && 'mb-4')}
/>
))}
</div>
{links.map((item, i) => (
<SidebarLinkItem
key={i}
item={item}
className={cn('lg:hidden', i === links.length - 1 && 'mb-4')}
/>
))}
<SidebarPageTree components={sidebarComponents} />
</SidebarViewport>
<SidebarFooter className={cn(!sidebarFooter && 'md:hidden')}>
@@ -145,6 +166,7 @@ export function DocsLayout({
links={links}
i18n={i18n}
sidebarCollapsible={sidebarCollapsible}
tabs={tabMode == 'navbar' ? tabs : []}
/>
<StylesProvider {...pageStyles}>{props.children}</StylesProvider>
</main>
@@ -158,67 +180,105 @@ function DocsNavbar({
links,
nav = {},
i18n,
tabs,
}: {
nav: DocsLayoutProps['nav'];
sidebarCollapsible: boolean;
i18n: boolean;
links: LinkItemType[];
tabs: Option[];
}) {
const navMode = nav.mode ?? 'auto';
return (
<Navbar>
{sidebarCollapsible ? (
<SidebarCollapseTrigger
<Navbar
className={cn('flex flex-col h-14', tabs.length > 0 && 'lg:h-26')}
style={
navMode === 'top'
? {
paddingInlineStart: 'var(--fd-layout-offset)',
}
: undefined
}
>
<div className="flex flex-row border-b border-fd-foreground/10 px-4 flex-1 md:px-6">
<div
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon',
}),
'-ms-1.5 text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden',
'flex flex-row items-center',
navMode === 'top' && 'flex-1',
)}
>
<Title
url={nav.url}
title={nav.title}
className={cn(navMode === 'auto' ? 'md:hidden' : 'pe-6')}
/>
{sidebarCollapsible ? (
<SidebarCollapseTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon',
}),
'text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden',
)}
/>
) : null}
</div>
<LargeSearchToggle
hideIfDisabled
className={cn(
'w-full my-auto rounded-xl max-md:hidden',
navMode === 'top' ? 'max-w-sm px-2' : 'max-w-[240px]',
)}
/>
) : null}
<LargeSearchToggle
hideIfDisabled
className="w-full max-w-[240px] rounded-lg max-md:hidden"
/>
<Title url={nav.url} title={nav.title} className="md:hidden" />
<div className="flex flex-1 flex-row items-center gap-6 px-2">
{links
.filter((item) => item.type !== 'icon')
.map((item, i) => (
<NavbarLinkItem
key={i}
item={item}
className="text-sm text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground max-lg:hidden"
/>
))}
{nav.children}
<div className="flex flex-1 flex-row items-center justify-end md:gap-2">
<div className="flex flex-row items-center gap-6 px-4 empty:hidden max-lg:hidden">
{links
.filter((item) => item.type !== 'icon')
.map((item, i) => (
<NavbarLinkItem
key={i}
item={item}
className="text-sm text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground"
/>
))}
</div>
{nav.children}
<SearchToggle hideIfDisabled className="md:hidden" />
<NavbarSidebarTrigger className="md:hidden" />
{links
.filter((item) => item.type === 'icon')
.map((item, i) => (
<BaseLinkItem
key={i}
item={item}
className={cn(
buttonVariants({ size: 'icon', color: 'ghost' }),
'text-fd-muted-foreground [&_svg]:size-4.5 max-lg:hidden',
)}
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
))}
{i18n ? (
<LanguageToggle>
<Languages className="size-5" />
</LanguageToggle>
) : null}
<ThemeToggle className="max-md:hidden" mode="light-dark-system" />
</div>
</div>
<SearchToggle hideIfDisabled className="md:hidden" />
<NavbarSidebarTrigger className="-me-1.5 md:hidden" />
<div className="flex flex-row items-center empty:hidden max-lg:hidden">
{links
.filter((item) => item.type === 'icon')
.map((item, i) => (
<BaseLinkItem
key={i}
item={item}
className={cn(
buttonVariants({ size: 'icon', color: 'ghost' }),
'text-fd-muted-foreground',
)}
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
{tabs.length > 0 ? (
<LayoutTabs className="px-6 border-b border-fd-foreground/10 max-lg:hidden">
{tabs.map((tab) => (
<LayoutTab key={tab.url} {...tab} />
))}
</div>
{i18n ? (
<LanguageToggle>
<Languages className="size-5" />
</LanguageToggle>
</LayoutTabs>
) : null}
<ThemeToggle className="max-md:hidden" mode="light-dark-system" />
</Navbar>
);
}
@@ -266,30 +326,3 @@ function NavbarLinkItem({
</BaseLinkItem>
);
}
function SidebarHeaderItems({
links,
nav = {},
children,
}: SharedNavProps & {
nav: DocsLayoutProps['nav'];
links: LinkItemType[];
children: ReactNode;
}) {
const isEmpty = !nav.title && !nav.children && links.length === 0;
if (isEmpty) return null;
return (
<div className="flex flex-row items-center max-md:hidden">
{nav.title ? (
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 py-1 font-medium"
>
{nav.title}
</Link>
) : null}
{children}
</div>
);
}