mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Support custom nav mode and tabs mode on Notebook layout
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
Support custom nav mode and tabs mode on Notebook layout
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user