UI: Cohesive page layout

This commit is contained in:
Fuma Nama
2025-11-22 18:50:18 +08:00
parent a70eae8d9c
commit a9f4edac09
34 changed files with 1918 additions and 800 deletions
+15
View File
@@ -0,0 +1,15 @@
---
'fumadocs-ui': minor
---
**Require importing page layout according to your docs layout:**
```ts
// for docs layout
import { DocsPage } from 'fumadocs-ui/layouts/docs/page';
// for notebook layout
import { DocsPage } from 'fumadocs-ui/layouts/notebook/page';
```
The default `fumadocs-ui/page` will redirect to `fumadocs-ui/layouts/docs/page`, if you're on Notebook layout, please update the import.
+1 -1
View File
@@ -82,7 +82,7 @@ export function Hero() {
size={3}
speed={0}
frame={5000 * 120}
className="absolute max-lg:bottom-[-50%] max-lg:left-[-200px] animate-fd-fade-in duration-400 lg:top-[-5%] lg:right-0"
className="absolute animate-fd-fade-in duration-400 max-lg:bottom-[-50%] max-lg:left-[-200px] lg:top-[-5%] lg:right-0"
minPixelRatio={1}
/>
)}
+1 -2
View File
@@ -1,7 +1,6 @@
import { type LinkItemType } from 'fumadocs-ui/layouts/docs';
import { AlbumIcon, Heart, LayoutTemplate } from 'lucide-react';
import Image from 'next/image';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import type { BaseLayoutProps, LinkItemType } from 'fumadocs-ui/layouts/shared';
import { FumadocsIcon } from '@/app/layout.client';
import Logo from '@/public/logo.png';
+30
View File
@@ -0,0 +1,30 @@
@source '../../dist/layouts/docs/**/*.js';
#nd-docs-layout {
--fd-sidebar-width: 0px;
--fd-toc-width: 0px;
--fd-layout-width: 1600px;
--fd-docs-toc-top: 0px;
--fd-docs-toc-popover-top: calc(14 * var(--spacing));
--fd-docs-sidebar-top: 0px;
--fd-docs-nav-top: 0px;
--fd-docs-sidebar-mobile-offset: 100%;
}
[dir='rtl'] #nd-docs-layout {
--fd-docs-sidebar-mobile-offset: -100%;
}
@variant md {
#nd-docs-layout {
--fd-sidebar-width: 268px;
--fd-docs-toc-popover-top: 0px;
}
}
@variant xl {
#nd-docs-layout {
--fd-toc-width: 268px;
}
}
+1
View File
@@ -0,0 +1 @@
@source '../../dist/layouts/home/**/*.js';
+32
View File
@@ -0,0 +1,32 @@
@source '../../dist/layouts/notebook/**/*.js';
@layer base {
#nd-notebook-layout {
--fd-page-width: 1200px;
--fd-sidebar-width: 0px;
--fd-toc-width: 0px;
--fd-layout-width: 1600px;
--fd-banner-height: 0px;
--fd-nav-height: 0px;
--fd-tocnav-height: 0px;
--fd-sidebar-mobile-offset: 100%;
--fd-layout-offset: max(calc(50vw - var(--fd-layout-width) / 2), 0px);
}
[dir='rtl'] #nd-notebook-layout {
--fd-sidebar-mobile-offset: -100%;
}
@variant xl {
#nd-notebook-layout {
--fd-toc-width: 286px;
}
}
}
/* Apply styles to #nd-notebook-layout */
@custom-variant on-notebook-layout {
#nd-notebook-layout:has(&) {
@slot;
}
}
+12 -26
View File
@@ -1,22 +1,20 @@
@import './shiki.css';
@import './components/image-zoom.css';
/* TODO: remove them on next major */
@import './layouts/docs.css';
@import './layouts/home.css';
@import './layouts/notebook.css';
@source '../dist/*.js';
@source '../dist/{components,contexts,provider,utils}/**/*.js';
@source '../dist/layouts/shared/**/*.js';
@source '../dist/**/*.js';
@plugin '../dist/theme/typography/index.js';
[dir='rtl'] {
--fd-sidebar-mobile-offset: -100%;
}
@theme {
--fd-sidebar-mobile-offset: 100%;
--spacing-fd-container: 1400px;
--fd-page-width: 1200px;
--fd-sidebar-width: 0px;
--fd-toc-width: 0px;
--fd-layout-width: 1600px;
--fd-banner-height: 0px;
--fd-nav-height: 0px;
--fd-tocnav-height: 0px;
--animate-fd-fade-in: fd-fade-in 300ms ease;
--animate-fd-fade-out: fd-fade-out 300ms ease;
@@ -230,11 +228,6 @@
background-color: var(--color-fd-background);
color: var(--color-fd-foreground);
}
:root,
#nd-docs-layout {
--fd-layout-offset: max(calc(50vw - var(--fd-layout-width) / 2), 0px);
}
}
@utility fd-scroll-container {
@@ -299,14 +292,7 @@
margin-inline: auto;
padding-inline: 1rem;
@apply 2xl:max-w-(--spacing-fd-container);
@apply 2xl:max-w-fd-container;
}
@variant dark (&:where(.dark, .dark *));
/* Apply styles to :root */
@custom-variant on-root {
:root:has(&) {
@slot;
}
}
+2 -2
View File
@@ -27,11 +27,11 @@
border: none;
}
.fd-default-layout #nd-page article {
#nd-docs-layout #nd-page article {
@apply mx-4;
}
.fd-notebook-layout #nd-page article {
#nd-notebook-layout #nd-page article {
@apply mx-6;
}
}
+7 -16
View File
@@ -37,33 +37,24 @@
"types": "./dist/layouts/docs/index.d.ts"
},
"./layouts/docs/page": {
"import": "./dist/layouts/docs/page.js",
"types": "./dist/layouts/docs/page.d.ts"
},
"./layouts/docs-client": {
"import": "./dist/layouts/docs/client.js",
"types": "./dist/layouts/docs/client.d.ts"
"import": "./dist/layouts/docs/page/index.js",
"types": "./dist/layouts/docs/page/index.d.ts"
},
"./layouts/notebook": {
"import": "./dist/layouts/notebook/index.js",
"types": "./dist/layouts/notebook/index.d.ts"
},
"./layouts/notebook-client": {
"import": "./dist/layouts/notebook/client.js",
"types": "./dist/layouts/notebook/client.d.ts"
},
"./layouts/links": {
"import": "./dist/layouts/shared/index.js",
"types": "./dist/layouts/shared/index.d.ts"
"./layouts/notebook/page": {
"import": "./dist/layouts/notebook/page/index.js",
"types": "./dist/layouts/notebook/page/index.d.ts"
},
"./layouts/shared": {
"import": "./dist/layouts/shared/index.js",
"types": "./dist/layouts/shared/index.d.ts"
},
"./page": {
"node": "./dist/page.js",
"import": "./dist/page.js",
"types": "./dist/page.d.ts"
"import": "./dist/layouts/docs/page/index.js",
"types": "./dist/layouts/docs/page/index.d.ts"
},
"./provider": {
"import": "./dist/provider/index.js",
+2 -3
View File
@@ -1,15 +1,14 @@
'use client';
import { Image, type ImageProps } from 'fumadocs-core/framework';
import { type ImgHTMLAttributes } from 'react';
import './image-zoom.css';
import type { ComponentProps } from 'react';
import Zoom, { type UncontrolledProps } from 'react-medium-image-zoom';
export type ImageZoomProps = ImageProps & {
/**
* Image props when zoom in
*/
zoomInProps?: ImgHTMLAttributes<HTMLImageElement>;
zoomInProps?: ComponentProps<'img'>;
/**
* Props for `react-medium-image-zoom`
@@ -2,16 +2,11 @@
import * as Primitive from 'fumadocs-core/toc';
import { type ComponentProps, useEffect, useRef, useState } from 'react';
import { cn } from '@/utils/cn';
import { TocThumb } from '@/components/layout/toc-thumb';
import { useTOCItems } from '@/components/layout/toc';
import { useTOCItems, TocThumb } from '.';
import { mergeRefs } from '@/utils/merge-refs';
import { useI18n } from '@/contexts/i18n';
export default function ClerkTOCItems({
ref,
className,
...props
}: ComponentProps<'div'>) {
export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
const containerRef = useRef<HTMLDivElement>(null);
const items = useTOCItems();
const { text } = useI18n();
@@ -77,7 +72,7 @@ export default function ClerkTOCItems({
return (
<>
{svg ? (
{svg && (
<div
className="absolute start-0 top-0 rtl:-scale-x-100"
style={{
@@ -96,7 +91,7 @@ export default function ClerkTOCItems({
className="mt-(--fd-top) h-(--fd-height) bg-fd-primary transition-all"
/>
</div>
) : null}
)}
<div
ref={mergeRefs(containerRef, ref)}
className={cn('flex flex-col', className)}
@@ -144,9 +139,9 @@ function TOCItem({
style={{
paddingInlineStart: getItemOffset(item.depth),
}}
className="prose relative py-1.5 text-sm text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
className="prose relative py-1.5 text-sm text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
>
{offset !== upperOffset ? (
{offset !== upperOffset && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@@ -161,7 +156,7 @@ function TOCItem({
strokeWidth="1"
/>
</svg>
) : null}
)}
<div
className={cn(
'absolute inset-y-0 w-px bg-fd-foreground/10',
@@ -1,53 +1,10 @@
'use client';
import * as Primitive from 'fumadocs-core/toc';
import { type ComponentProps, createContext, useContext, useRef } from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TocThumb } from '@/components/layout/toc-thumb';
import { cn } from '@/utils/cn';
import { mergeRefs } from '@/utils/merge-refs';
const TOCContext = createContext<Primitive.TOCItemType[]>([]);
export function useTOCItems(): Primitive.TOCItemType[] {
return useContext(TOCContext);
}
export function TOCProvider({
toc,
children,
...props
}: ComponentProps<typeof Primitive.AnchorProvider>) {
return (
<TOCContext value={toc}>
<Primitive.AnchorProvider toc={toc} {...props}>
{children}
</Primitive.AnchorProvider>
</TOCContext>
);
}
export function TOCScrollArea({
ref,
className,
...props
}: ComponentProps<'div'>) {
const viewRef = useRef<HTMLDivElement>(null);
return (
<div
ref={mergeRefs(viewRef, ref)}
className={cn(
'relative min-h-0 text-sm ms-px overflow-auto [scrollbar-width:none] [mask-image:linear-gradient(to_bottom,transparent,white_16px,white_calc(100%-16px),transparent)] py-3',
className,
)}
{...props}
>
<Primitive.ScrollProvider containerRef={viewRef}>
{props.children}
</Primitive.ScrollProvider>
</div>
);
}
import { type ComponentProps, useRef } from 'react';
import { useTOCItems, TocThumb } from '.';
import * as Primitive from 'fumadocs-core/toc';
export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -88,7 +45,7 @@ function TOCItem({ item }: { item: Primitive.TOCItemType }) {
<Primitive.TOCItem
href={item.url}
className={cn(
'prose py-1.5 text-sm text-fd-muted-foreground transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
'prose py-1.5 text-sm text-fd-muted-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
item.depth <= 2 && 'ps-3',
item.depth === 3 && 'ps-6',
item.depth >= 4 && 'ps-8',
@@ -1,11 +1,59 @@
'use client';
import * as Primitive from 'fumadocs-core/toc';
import {
type HTMLAttributes,
type ComponentProps,
createContext,
use,
type RefObject,
useEffect,
useEffectEvent,
useRef,
} from 'react';
import { useActiveAnchors } from 'fumadocs-core/toc';
import { cn } from '@/utils/cn';
import { mergeRefs } from '@/utils/merge-refs';
const TOCContext = createContext<Primitive.TOCItemType[]>([]);
export function useTOCItems(): Primitive.TOCItemType[] {
return use(TOCContext);
}
export function TOCProvider({
toc,
children,
...props
}: ComponentProps<typeof Primitive.AnchorProvider>) {
return (
<TOCContext value={toc}>
<Primitive.AnchorProvider toc={toc} {...props}>
{children}
</Primitive.AnchorProvider>
</TOCContext>
);
}
export function TOCScrollArea({
ref,
className,
...props
}: ComponentProps<'div'>) {
const viewRef = useRef<HTMLDivElement>(null);
return (
<div
ref={mergeRefs(viewRef, ref)}
className={cn(
'relative min-h-0 text-sm ms-px overflow-auto [scrollbar-width:none] mask-[linear-gradient(to_bottom,transparent,white_16px,white_calc(100%-16px),transparent)] py-3',
className,
)}
{...props}
>
<Primitive.ScrollProvider containerRef={viewRef}>
{props.children}
</Primitive.ScrollProvider>
</div>
);
}
type TocThumb = [top: number, height: number];
@@ -16,7 +64,7 @@ interface RefProps {
export function TocThumb({
containerRef,
...props
}: HTMLAttributes<HTMLDivElement> & RefProps) {
}: ComponentProps<'div'> & RefProps) {
const thumbRef = useRef<HTMLDivElement>(null);
return (
@@ -31,7 +79,7 @@ function Updater({
containerRef,
thumbRef,
}: RefProps & { thumbRef: RefObject<HTMLElement | null> }) {
const active = useActiveAnchors();
const active = Primitive.useActiveAnchors();
const onPrint = useEffectEvent(() => {
if (!containerRef.current || !thumbRef.current) return;
+7 -42
View File
@@ -6,67 +6,32 @@ import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { useSidebar } from '@/contexts/sidebar';
import { useNav } from '@/contexts/layout';
import { SidebarCollapseTrigger } from '@/components/layout/sidebar';
import { SearchToggle } from '@/components/layout/search-toggle';
import type { Option } from '@/components/layout/root-toggle';
import { SidebarCollapseTrigger } from './sidebar';
import { SearchToggle } from '../shared/search-toggle';
import type { Option } from '@/layouts/shared/root-toggle';
import { usePathname } from 'fumadocs-core/framework';
import { isTabActive } from '@/utils/is-active';
import Link from 'fumadocs-core/link';
export function Navbar(props: ComponentProps<'header'>) {
export function LayoutHeader(props: ComponentProps<'header'>) {
const { isTransparent } = useNav();
return (
<header
id="nd-subnav"
{...props}
className={cn(
'fixed top-(--fd-banner-height) left-0 right-(--removed-body-scroll-bar-size,0) z-30 flex items-center ps-4 pe-2.5 border-b transition-colors backdrop-blur-sm',
!isTransparent && 'bg-fd-background/80',
props.className,
)}
>
<header data-transparent={isTransparent} {...props}>
{props.children}
</header>
);
}
export function LayoutBody(props: ComponentProps<'main'>) {
const { collapsed } = useSidebar();
return (
<main
id="nd-docs-layout"
{...props}
className={cn(
'flex flex-1 flex-col pt-(--fd-nav-height) transition-[padding] fd-default-layout',
!collapsed && 'mx-(--fd-layout-offset)',
props.className,
)}
style={{
...props.style,
paddingInlineStart: collapsed
? 'min(calc(100vw - var(--fd-page-width)), var(--fd-sidebar-width))'
: 'var(--fd-sidebar-width)',
}}
>
{props.children}
</main>
);
}
export function CollapsibleControl() {
const { collapsed } = useSidebar();
return (
<div
className={cn(
'fixed flex shadow-lg transition-opacity rounded-xl p-0.5 border bg-fd-muted text-fd-muted-foreground z-10 max-md:hidden xl:start-4 max-xl:end-4',
'fixed flex top-4 shadow-lg transition-opacity rounded-xl p-0.5 border bg-fd-muted text-fd-muted-foreground z-10 max-md:hidden xl:start-4 max-xl:end-4',
!collapsed && 'pointer-events-none opacity-0',
)}
style={{
top: 'calc(var(--fd-banner-height) + var(--fd-tocnav-height) + var(--spacing) * 4)',
}}
>
<SidebarCollapseTrigger
className={cn(
@@ -99,7 +64,7 @@ export function LayoutTabs({
<div
{...props}
className={cn(
'flex flex-row items-end gap-6 overflow-auto',
'flex flex-row items-end gap-6 overflow-auto [grid-area:main]',
props.className,
)}
>
+65 -115
View File
@@ -14,43 +14,30 @@ import {
type SidebarComponents,
SidebarContent,
SidebarContentMobile,
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarFooter,
SidebarHeader,
SidebarItem,
SidebarLinkItem,
SidebarPageTree,
type SidebarProps,
SidebarTrigger,
SidebarViewport,
} from '@/components/layout/sidebar';
import { type Option, RootToggle } from '@/components/layout/root-toggle';
import {
type BaseLayoutProps,
BaseLinkItem,
getLinks,
type LinkItemType,
} from '@/layouts/shared';
} from './sidebar';
import { type Option, RootToggle } from '@/layouts/shared/root-toggle';
import { type BaseLayoutProps, resolveLinkItems } from '@/layouts/shared';
import { LinkItem } from '@/layouts/shared/link-item';
import {
LanguageToggle,
LanguageToggleText,
} from '@/components/layout/language-toggle';
import {
CollapsibleControl,
LayoutBody,
LayoutTabs,
Navbar,
} from '@/layouts/docs/client';
} from '@/layouts/shared/language-toggle';
import { CollapsibleControl, LayoutTabs, LayoutHeader } from './client';
import { TreeContextProvider } from '@/contexts/tree';
import { ThemeToggle } from '@/components/layout/theme-toggle';
import { ThemeToggle } from '../shared/theme-toggle';
import { NavProvider } from '@/contexts/layout';
import Link from 'fumadocs-core/link';
import {
LargeSearchToggle,
SearchToggle,
} from '@/components/layout/search-toggle';
} from '@/layouts/shared/search-toggle';
import {
getSidebarTabs,
type GetSidebarTabsOptions,
@@ -119,10 +106,7 @@ export function DocsLayout({
}
return [];
}, [tree, sidebarTabs]);
const links = getLinks(props.links ?? [], props.githubUrl);
const sidebarVariables = cn(
'md:[--fd-sidebar-width:268px] lg:[--fd-sidebar-width:286px]',
);
const links = resolveLinkItems(props);
function sidebar() {
const {
@@ -160,7 +144,7 @@ export function DocsLayout({
<div className="flex text-fd-muted-foreground items-center gap-1.5">
<div className="flex flex-1">
{iconLinks.map((item, i) => (
<BaseLinkItem
<LinkItem
key={i}
item={item}
className={cn(
@@ -173,15 +157,15 @@ export function DocsLayout({
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
</LinkItem>
))}
</div>
{i18n ? (
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
<LanguageToggleText />
</LanguageToggle>
) : null}
)}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle className="p-0" mode={themeSwitch.mode} />
@@ -207,7 +191,13 @@ export function DocsLayout({
);
const content = (
<SidebarContent {...rest}>
<SidebarContent
{...rest}
className={cn(
'sticky top-(--fd-docs-sidebar-top) [grid-area:sidebar] h-[calc(100dvh-var(--fd-docs-sidebar-top))] flex flex-col items-end z-20 bg-fd-card text-sm border-e min-h-0 transition-[top,opacity,translate,width] duration-200 max-md:hidden *:w-(--fd-sidebar-width)',
rest.className,
)}
>
<SidebarHeader>
<div className="flex">
<Link
@@ -253,7 +243,7 @@ export function DocsLayout({
</LanguageToggle>
)}
{iconLinks.map((item, i) => (
<BaseLinkItem
<LinkItem
key={i}
item={item}
className={cn(
@@ -262,7 +252,7 @@ export function DocsLayout({
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
</LinkItem>
))}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
@@ -296,98 +286,58 @@ export function DocsLayout({
return (
<TreeContextProvider tree={tree}>
<NavProvider transparentMode={transparentMode}>
{nav.enabled !== false &&
(nav.component ?? (
<Navbar className="h-(--fd-nav-height) on-root:[--fd-nav-height:56px] md:on-root:[--fd-nav-height:0px] md:hidden">
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-semibold"
>
{nav.title}
</Link>
<div className="flex-1">{nav.children}</div>
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="p-2" hideIfDisabled />
))}
{sidebarEnabled && (
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
)}
>
<SidebarIcon />
</SidebarTrigger>
)}
</Navbar>
))}
<LayoutBody
<div
id="nd-docs-layout"
{...props.containerProps}
className={cn(
'md:[&_#nd-page_article]:pt-12 xl:[&_#nd-page_article]:px-8',
sidebarEnabled && sidebarVariables,
props.containerProps?.className,
)}
className="grid"
style={{
gridTemplate: `"sidebar header header"
"sidebar toc-popover toc-popover"
"sidebar main toc" 1fr / minmax(var(--fd-sidebar-width), 1fr) minmax(0px, 900px) minmax(var(--fd-toc-width), 1fr)`,
}}
>
{nav.enabled !== false &&
(nav.component ?? (
<LayoutHeader
id="nd-subnav"
className="[grid-area:header] sticky top-(--fd-docs-nav-top) z-30 flex items-center ps-4 pe-2.5 border-b transition-colors backdrop-blur-sm h-14 md:hidden data-[transparent=false]:bg-fd-background/80"
>
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-semibold"
>
{nav.title}
</Link>
<div className="flex-1">{nav.children}</div>
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="p-2" hideIfDisabled />
))}
{sidebarEnabled && (
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
)}
>
<SidebarIcon />
</SidebarTrigger>
)}
</LayoutHeader>
))}
{sidebarEnabled && sidebar()}
{tabMode === 'top' && tabs.length > 0 && (
<LayoutTabs
options={tabs}
className="sticky top-[calc(var(--fd-nav-height)+var(--fd-tocnav-height))] z-10 bg-fd-background border-b px-6 pt-3 xl:px-8 max-md:hidden"
className="z-10 bg-fd-background border-b px-6 pt-3 xl:px-8 max-md:hidden"
/>
)}
{children}
</LayoutBody>
</div>
</NavProvider>
</TreeContextProvider>
);
}
function SidebarLinkItem({
item,
...props
}: {
item: Exclude<LinkItemType, { type: 'icon' }>;
className?: string;
}) {
if (item.type === 'menu')
return (
<SidebarFolder {...props}>
{item.url ? (
<SidebarFolderLink href={item.url} external={item.external}>
{item.icon}
{item.text}
</SidebarFolderLink>
) : (
<SidebarFolderTrigger>
{item.icon}
{item.text}
</SidebarFolderTrigger>
)}
<SidebarFolderContent>
{item.items.map((child, i) => (
<SidebarLinkItem key={i} item={child} />
))}
</SidebarFolderContent>
</SidebarFolder>
);
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
return (
<SidebarItem
href={item.url}
icon={item.icon}
external={item.external}
{...props}
>
{item.text}
</SidebarItem>
);
}
export { CollapsibleControl, Navbar, SidebarTrigger, type LinkItemType };
-104
View File
@@ -1,104 +0,0 @@
import { type ComponentProps } from 'react';
import { cn } from '@/utils/cn';
import {
type BreadcrumbProps,
type FooterProps,
PageBreadcrumb,
PageFooter,
PageLastUpdate,
PageTOC,
PageTOCPopover,
PageTOCPopoverContent,
PageTOCPopoverTrigger,
} from './page-client';
import { TOCItems, TOCProvider, TOCScrollArea } from '@/components/layout/toc';
import { Text } from 'lucide-react';
import { I18nLabel } from '@/contexts/i18n';
import ClerkTOCItems from '@/components/layout/toc-clerk';
import type { AnchorProviderProps } from 'fumadocs-core/toc';
export function PageTOCTitle(props: ComponentProps<'h2'>) {
return (
<h3
id="toc-title"
{...props}
className={cn(
'inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground',
props.className,
)}
>
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
);
}
export function PageTOCItems({
variant = 'normal',
...props
}: ComponentProps<'div'> & { variant?: 'clerk' | 'normal' }) {
return (
<TOCScrollArea {...props}>
{variant === 'clerk' ? <ClerkTOCItems /> : <TOCItems />}
</TOCScrollArea>
);
}
export function PageTOCPopoverItems({
variant = 'normal',
...props
}: ComponentProps<'div'> & { variant?: 'clerk' | 'normal' }) {
return (
<TOCScrollArea {...props}>
{variant === 'clerk' ? <ClerkTOCItems /> : <TOCItems />}
</TOCScrollArea>
);
}
export function PageArticle(props: ComponentProps<'article'>) {
return (
<article
{...props}
className={cn(
'flex min-w-0 w-full flex-col gap-4 pt-8 px-4 md:px-6 md:mx-auto',
props.className,
)}
>
{props.children}
</article>
);
}
export interface RootProps extends ComponentProps<'div'> {
toc?: Omit<AnchorProviderProps, 'children'> | false;
}
export function PageRoot({ toc = false, children, ...props }: RootProps) {
const content = (
<div
id="nd-page"
{...props}
className={cn(
'flex flex-1 w-full mx-auto max-w-(--fd-page-width) pt-(--fd-tocnav-height) pe-(--fd-toc-width)',
props.className,
)}
>
{children}
</div>
);
if (toc) return <TOCProvider {...toc}>{content}</TOCProvider>;
return content;
}
export {
PageBreadcrumb,
PageFooter,
PageLastUpdate,
PageTOC,
PageTOCPopover,
PageTOCPopoverTrigger,
PageTOCPopoverContent,
type FooterProps,
type BreadcrumbProps,
};
@@ -0,0 +1,388 @@
'use client';
import {
type ComponentProps,
Fragment,
useEffect,
useEffectEvent,
createContext,
useMemo,
useRef,
useState,
use,
} from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from '@/icons';
import Link from 'fumadocs-core/link';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import type * as PageTree from 'fumadocs-core/page-tree';
import { usePathname } from 'fumadocs-core/framework';
import {
type BreadcrumbOptions,
getBreadcrumbItemsFromPath,
} from 'fumadocs-core/breadcrumb';
import { useNav } from '@/contexts/layout';
import { isActive } from '@/utils/is-active';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { useTOCItems } from '@/components/toc';
import { useActiveAnchor } from 'fumadocs-core/toc';
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export function PageTOCPopover(props: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isTransparent } = useNav();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement))
setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
className="sticky top-(--fd-docs-toc-popover-top) z-10 [grid-area:toc-popover] h-10 xl:hidden"
>
<header
ref={ref}
id="nd-tocnav"
{...props}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
props.className,
)}
>
{props.children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
export function PageTOCPopoverTrigger(props: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = useTOCItems();
const active = useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
{...props}
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
props.className,
)}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-all',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-all',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown
className={cn(
'shrink-0 transition-transform mx-0.5',
open && 'rotate-180',
)}
/>
</CollapsibleTrigger>
);
}
interface ProgressCircleProps
extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
export function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
export function PageLastUpdate({
date: value,
...props
}: Omit<ComponentProps<'p'>, 'children'> & { date: Date | string }) {
const { text } = useI18n();
const [date, setDate] = useState('');
useEffect(() => {
// to the timezone of client
setDate(new Date(value).toLocaleDateString());
}, [value]);
return (
<p
{...props}
className={cn('text-sm text-fd-muted-foreground', props.className)}
>
{text.lastUpdate} {date}
</p>
);
}
type Item = Pick<PageTree.Item, 'name' | 'description' | 'url'>;
export interface FooterProps extends ComponentProps<'div'> {
/**
* Items including information for the next and previous page
*/
items?: {
previous?: Item;
next?: Item;
};
}
function scanNavigationList(tree: PageTree.Node[]) {
const list: PageTree.Item[] = [];
tree.forEach((node) => {
if (node.type === 'folder') {
if (node.index) {
list.push(node.index);
}
list.push(...scanNavigationList(node.children));
return;
}
if (node.type === 'page' && !node.external) {
list.push(node);
}
});
return list;
}
const listCache = new Map<string, PageTree.Item[]>();
export function PageFooter({ items, ...props }: FooterProps) {
const { root } = useTreeContext();
const pathname = usePathname();
const { previous, next } = useMemo(() => {
if (items) return items;
const cached = listCache.get(root.$id);
const list = cached ?? scanNavigationList(root.children);
listCache.set(root.$id, list);
const idx = list.findIndex((item) => isActive(item.url, pathname, false));
if (idx === -1) return {};
return {
previous: list[idx - 1],
next: list[idx + 1],
};
}, [items, pathname, root]);
return (
<div
{...props}
className={cn(
'@container grid gap-4',
previous && next ? 'grid-cols-2' : 'grid-cols-1',
props.className,
)}
>
{previous ? <FooterItem item={previous} index={0} /> : null}
{next ? <FooterItem item={next} index={1} /> : null}
</div>
);
}
function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) {
const { text } = useI18n();
const Icon = index === 0 ? ChevronLeft : ChevronRight;
return (
<Link
href={item.url}
className={cn(
'flex flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground @max-lg:col-span-full',
index === 1 && 'text-end',
)}
>
<div
className={cn(
'inline-flex items-center gap-1.5 font-medium',
index === 1 && 'flex-row-reverse',
)}
>
<Icon className="-mx-1 size-4 shrink-0 rtl:rotate-180" />
<p>{item.name}</p>
</div>
<p className="text-fd-muted-foreground truncate">
{item.description ?? (index === 0 ? text.previousPage : text.nextPage)}
</p>
</Link>
);
}
export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>;
export function PageBreadcrumb({
includeRoot,
includeSeparator,
includePage,
...props
}: BreadcrumbProps) {
const path = useTreePath();
const { root } = useTreeContext();
const items = useMemo(() => {
return getBreadcrumbItemsFromPath(root, path, {
includePage,
includeSeparator,
includeRoot,
});
}, [includePage, includeRoot, includeSeparator, path, root]);
if (items.length === 0) return null;
return (
<div
{...props}
className={cn(
'flex items-center gap-1.5 text-sm text-fd-muted-foreground',
props.className,
)}
>
{items.map((item, i) => {
const className = cn(
'truncate',
i === items.length - 1 && 'text-fd-primary font-medium',
);
return (
<Fragment key={i}>
{i !== 0 && <ChevronRight className="size-3.5 shrink-0" />}
{item.url ? (
<Link
href={item.url}
className={cn(className, 'transition-opacity hover:opacity-80')}
>
{item.name}
</Link>
) : (
<span className={className}>{item.name}</span>
)}
</Fragment>
);
})}
</div>
);
}
+288
View File
@@ -0,0 +1,288 @@
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { Edit, Text } from 'lucide-react';
import { I18nLabel } from '@/contexts/i18n';
import {
type BreadcrumbProps,
type FooterProps,
PageBreadcrumb,
PageFooter,
PageLastUpdate,
PageTOCPopover,
PageTOCPopoverContent,
PageTOCPopoverTrigger,
} from './client';
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';
interface BreadcrumbOptions extends BreadcrumbProps {
enabled: boolean;
component: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled: boolean;
component: ReactNode;
}
interface EditOnGitHubOptions
extends Omit<ComponentProps<'a'>, 'href' | 'children'> {
owner: string;
repo: string;
/**
* SHA or ref (branch or tag) name.
*
* @defaultValue main
*/
sha?: string;
/**
* File path in the repo
*/
path: string;
}
export interface DocsPageProps {
toc?: TOCItemType[];
tableOfContent?: Partial<TableOfContentOptions>;
tableOfContentPopover?: Partial<TableOfContentPopoverOptions>;
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
editOnGithub?: EditOnGitHubOptions;
lastUpdate?: Date | string | number;
/**
* Replace or disable breadcrumb
*/
breadcrumb?: Partial<BreadcrumbOptions>;
/**
* Footer navigation, you can disable it by passing `false`
*/
footer?: Partial<FooterOptions>;
children?: ReactNode;
}
type TableOfContentOptions = Pick<AnchorProviderProps, 'single'> & {
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
enabled: boolean;
component: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
};
type TableOfContentPopoverOptions = Omit<TableOfContentOptions, 'single'>;
export function DocsPage({
breadcrumb: {
enabled: breadcrumbEnabled = true,
component: breadcrumb,
...breadcrumbProps
} = {},
footer = {},
full = false,
tableOfContentPopover: {
enabled: tocPopoverEnabled,
component: tocPopover,
...tocPopoverOptions
} = {},
tableOfContent: {
enabled: tocEnabled,
component: tocReplace,
...tocOptions
} = {},
toc = [],
editOnGithub,
lastUpdate,
children,
}: DocsPageProps) {
// 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>
);
}
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"
className="flex flex-col min-w-0 [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14"
>
{breadcrumbEnabled &&
(breadcrumb ?? <PageBreadcrumb {...breadcrumbProps} />)}
{children}
<div className="flex flex-row flex-wrap items-center justify-between gap-4 empty:hidden">
{editOnGithub && (
<EditOnGitHub
href={`https://github.com/${editOnGithub.owner}/${editOnGithub.repo}/blob/${editOnGithub.sha}/${editOnGithub.path.startsWith('/') ? editOnGithub.path.slice(1) : editOnGithub.path}`}
/>
)}
{lastUpdate && <PageLastUpdate date={new Date(lastUpdate)} />}
</div>
{footer.enabled !== false &&
(footer.component ?? <PageFooter items={footer.items} />)}
</article>
{tocEnabled &&
(tocReplace ?? (
<div
id="nd-toc"
className="sticky top-(--fd-docs-toc-top) h-[calc(100dvh-var(--fd-docs-toc-top))] flex flex-col min-h-0 [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>
))}
</>,
);
}
export function EditOnGitHub(props: ComponentProps<'a'>) {
return (
<a
target="_blank"
rel="noreferrer noopener"
{...props}
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'gap-1.5 not-prose',
}),
props.className,
)}
>
{props.children ?? (
<>
<Edit className="size-3.5" />
<I18nLabel label="editOnGithub" />
</>
)}
</a>
);
}
/**
* Add typography styles
*/
export function DocsBody({
children,
className,
...props
}: ComponentProps<'div'>) {
return (
<div {...props} className={cn('prose flex-1', className)}>
{children}
</div>
);
}
export function DocsDescription({
children,
className,
...props
}: ComponentProps<'p'>) {
// Don't render if no description provided
if (children === undefined) return null;
return (
<p
{...props}
className={cn('mb-8 text-lg text-fd-muted-foreground', className)}
>
{children}
</p>
);
}
export function DocsTitle({
children,
className,
...props
}: ComponentProps<'h1'>) {
return (
<h1 {...props} className={cn('text-[1.75em] font-semibold', className)}>
{children}
</h1>
);
}
/**
* For separate MDX page
*/
export function withArticle(props: ComponentProps<'main'>) {
return (
<main {...props} className={cn('container py-12', props.className)}>
<article className="prose">{props.children}</article>
</main>
);
}
+589
View File
@@ -0,0 +1,589 @@
'use client';
import { ChevronDown, ExternalLink } from 'lucide-react';
import { usePathname } from 'fumadocs-core/framework';
import {
type ComponentProps,
createContext,
type FC,
Fragment,
type ReactNode,
use,
useMemo,
useRef,
useState,
} from 'react';
import Link, { type LinkProps } from 'fumadocs-core/link';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { cn } from '@/utils/cn';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { isActive } from '@/utils/is-active';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { type ScrollAreaProps } from '@radix-ui/react-scroll-area';
import { useSidebar } from '@/contexts/sidebar';
import { cva } from 'class-variance-authority';
import type {
CollapsibleContentProps,
CollapsibleTriggerProps,
} from '@radix-ui/react-collapsible';
import type * as PageTree from 'fumadocs-core/page-tree';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import { useMediaQuery } from 'fumadocs-core/utils/use-media-query';
import { Presence } from '@radix-ui/react-presence';
import type { LinkItemType } from '@/layouts/shared';
export interface SidebarProps {
/**
* Open folders by default if their level is lower or equal to a specific level
* (Starting from 1)
*
* @defaultValue 0
*/
defaultOpenLevel?: number;
/**
* Prefetch links
*
* @defaultValue true
*/
prefetch?: boolean;
/**
* Children to render
*/
Content: ReactNode;
/**
* Alternative children for mobile
*/
Mobile?: ReactNode;
}
interface InternalContext {
defaultOpenLevel: number;
prefetch: boolean;
level: number;
}
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 ps-(--sidebar-item-offset) text-start text-fd-muted-foreground [overflow-wrap:anywhere] [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
active: {
true: 'bg-fd-primary/10 text-fd-primary',
false:
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
},
},
},
);
const Context = createContext<InternalContext | null>(null);
const FolderContext = createContext<{
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
} | null>(null);
export function Sidebar({
defaultOpenLevel = 0,
prefetch = true,
Mobile,
Content,
}: SidebarProps) {
const isMobile = useMediaQuery('(width < 768px)') ?? false;
const context = useMemo<InternalContext>(() => {
return {
defaultOpenLevel,
prefetch,
level: 1,
};
}, [defaultOpenLevel, prefetch]);
return (
<Context.Provider value={context}>
{isMobile && Mobile != null ? Mobile : Content}
</Context.Provider>
);
}
export function SidebarContent(props: ComponentProps<'aside'>) {
const { collapsed } = useSidebar();
const [hover, setHover] = useState(false);
const timerRef = useRef(0);
const closeTimeRef = useRef(0);
useOnChange(collapsed, () => {
setHover(false);
closeTimeRef.current = Date.now() + 150;
});
return (
<aside
id="nd-sidebar"
{...props}
data-collapsed={collapsed}
className={cn(
props.className,
collapsed && [
'rounded-xl border translate-x-(--fd-sidebar-offset) rtl:-translate-x-(--fd-sidebar-offset)',
hover ? 'z-50 shadow-lg' : 'opacity-0',
],
)}
onPointerEnter={(e) => {
if (
!collapsed ||
e.pointerType === 'touch' ||
closeTimeRef.current > Date.now()
)
return;
window.clearTimeout(timerRef.current);
setHover(true);
}}
onPointerLeave={(e) => {
if (!collapsed || e.pointerType === 'touch') return;
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(
() => {
setHover(false);
closeTimeRef.current = Date.now() + 150;
},
Math.min(e.clientX, document.body.clientWidth - e.clientX) > 100
? 0
: 500,
);
}}
>
{props.children}
</aside>
);
}
export function SidebarContentMobile({
className,
children,
...props
}: ComponentProps<'aside'>) {
const { open, setOpen } = useSidebar();
const state = open ? 'open' : 'closed';
return (
<>
<Presence present={open}>
<div
data-state={state}
className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out"
onClick={() => setOpen(false)}
/>
</Presence>
<Presence present={open}>
{({ present }) => (
<aside
id="nd-sidebar-mobile"
{...props}
data-state={state}
className={cn(
'fixed text-[0.9375rem] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
!present && 'invisible',
className,
)}
>
{children}
</aside>
)}
</Presence>
</>
);
}
export function SidebarHeader(props: ComponentProps<'div'>) {
return (
<div
{...props}
className={cn('flex flex-col gap-3 p-4 pb-2', props.className)}
>
{props.children}
</div>
);
}
export function SidebarFooter(props: ComponentProps<'div'>) {
return (
<div
{...props}
className={cn('flex flex-col border-t p-4 pt-2', props.className)}
>
{props.children}
</div>
);
}
export function SidebarViewport(props: ScrollAreaProps) {
return (
<ScrollArea {...props} className={cn('min-h-0 flex-1', props.className)}>
<ScrollViewport
className="p-4 overscroll-contain"
style={
{
'--sidebar-item-offset': 'calc(var(--spacing) * 2)',
maskImage:
'linear-gradient(to bottom, transparent, white 12px, white calc(100% - 12px), transparent)',
} as object
}
>
{props.children}
</ScrollViewport>
</ScrollArea>
);
}
export function SidebarSeparator(props: ComponentProps<'p'>) {
return (
<p
{...props}
className={cn(
'inline-flex items-center gap-2 mb-1.5 px-2 ps-(--sidebar-item-offset) empty:mb-0 [&_svg]:size-4 [&_svg]:shrink-0',
props.className,
)}
>
{props.children}
</p>
);
}
export function SidebarItem({
icon,
...props
}: LinkProps & {
icon?: ReactNode;
}) {
const pathname = usePathname();
const active =
props.href !== undefined && isActive(props.href, pathname, false);
const { prefetch } = useInternalContext();
return (
<Link
{...props}
data-active={active}
className={cn(itemVariants({ active }), props.className)}
prefetch={prefetch}
>
{icon ?? (props.external ? <ExternalLink /> : null)}
{props.children}
</Link>
);
}
export function SidebarFolder({
defaultOpen = false,
...props
}: ComponentProps<'div'> & {
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
useOnChange(defaultOpen, (v) => {
if (v) setOpen(v);
});
return (
<Collapsible open={open} onOpenChange={setOpen} {...props}>
<FolderContext.Provider
value={useMemo(() => ({ open, setOpen }), [open])}
>
{props.children}
</FolderContext.Provider>
</Collapsible>
);
}
export function SidebarFolderTrigger({
className,
...props
}: CollapsibleTriggerProps) {
const { open } = useFolderContext();
return (
<CollapsibleTrigger
className={cn(itemVariants({ active: false }), 'w-full', className)}
{...props}
>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</CollapsibleTrigger>
);
}
export function SidebarFolderLink(props: LinkProps) {
const { open, setOpen } = useFolderContext();
const { prefetch } = useInternalContext();
const pathname = usePathname();
const active =
props.href !== undefined && isActive(props.href, pathname, false);
return (
<Link
{...props}
data-active={active}
className={cn(itemVariants({ active }), 'w-full', props.className)}
onClick={(e) => {
if (
e.target instanceof Element &&
e.target.matches('[data-icon], [data-icon] *')
) {
setOpen(!open);
e.preventDefault();
} else {
setOpen(active ? !open : true);
}
}}
prefetch={prefetch}
>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</Link>
);
}
export function SidebarFolderContent(props: CollapsibleContentProps) {
const { level, ...ctx } = useInternalContext();
return (
<CollapsibleContent
{...props}
className={cn(
'relative',
level === 1 && [
"before:content-[''] before:absolute before:w-px before:inset-y-1 before:bg-fd-border before:start-2.5",
"**:data-[active=true]:before:content-[''] **:data-[active=true]:before:bg-fd-primary **:data-[active=true]:before:absolute **:data-[active=true]:before:w-px **:data-[active=true]:before:inset-y-2.5 **:data-[active=true]:before:start-2.5",
],
props.className,
)}
style={
{
'--sidebar-item-offset': `calc(var(--spacing) * ${(level + 1) * 3})`,
...props.style,
} as object
}
>
<Context.Provider
value={useMemo(
() => ({
...ctx,
level: level + 1,
}),
[ctx, level],
)}
>
{props.children}
</Context.Provider>
</CollapsibleContent>
);
}
export function SidebarTrigger({
children,
...props
}: ComponentProps<'button'>) {
const { setOpen } = useSidebar();
return (
<button
{...props}
aria-label="Open Sidebar"
onClick={() => setOpen((prev) => !prev)}
>
{children}
</button>
);
}
export function SidebarCollapseTrigger(props: ComponentProps<'button'>) {
const { collapsed, setCollapsed } = useSidebar();
return (
<button
type="button"
aria-label="Collapse Sidebar"
data-collapsed={collapsed}
{...props}
onClick={() => {
setCollapsed((prev) => !prev);
}}
>
{props.children}
</button>
);
}
function useFolderContext() {
const ctx = use(FolderContext);
if (!ctx) throw new Error('Missing sidebar folder');
return ctx;
}
function useInternalContext() {
const ctx = use(Context);
if (!ctx) throw new Error('<Sidebar /> component required.');
return ctx;
}
export interface SidebarComponents {
Item: FC<{ item: PageTree.Item }>;
Folder: FC<{ item: PageTree.Folder; level: number; children: ReactNode }>;
Separator: FC<{ item: PageTree.Separator }>;
}
/**
* Render sidebar items from page tree
*/
export function SidebarPageTree(props: {
components?: Partial<SidebarComponents>;
}) {
const { root } = useTreeContext();
return useMemo(() => {
const { Separator, Item, Folder } = props.components ?? {};
function renderSidebarList(
items: PageTree.Node[],
level: number,
): ReactNode[] {
return items.map((item, i) => {
if (item.type === 'separator') {
if (Separator) return <Separator key={i} item={item} />;
return (
<SidebarSeparator key={i} className={cn(i !== 0 && 'mt-6')}>
{item.icon}
{item.name}
</SidebarSeparator>
);
}
if (item.type === 'folder') {
const children = renderSidebarList(item.children, level + 1);
if (Folder)
return (
<Folder key={i} item={item} level={level}>
{children}
</Folder>
);
return (
<PageTreeFolder key={i} item={item}>
{children}
</PageTreeFolder>
);
}
if (Item) return <Item key={item.url} item={item} />;
return (
<SidebarItem
key={item.url}
href={item.url}
external={item.external}
icon={item.icon}
>
{item.name}
</SidebarItem>
);
});
}
return (
<Fragment key={root.$id}>{renderSidebarList(root.children, 1)}</Fragment>
);
}, [props.components, root]);
}
function PageTreeFolder({
item,
...props
}: {
item: PageTree.Folder;
children: ReactNode;
}) {
const { defaultOpenLevel, level } = useInternalContext();
const path = useTreePath();
return (
<SidebarFolder
defaultOpen={
(item.defaultOpen ?? defaultOpenLevel >= level) || path.includes(item)
}
>
{item.index ? (
<SidebarFolderLink
href={item.index.url}
external={item.index.external}
{...props}
>
{item.icon}
{item.name}
</SidebarFolderLink>
) : (
<SidebarFolderTrigger {...props}>
{item.icon}
{item.name}
</SidebarFolderTrigger>
)}
<SidebarFolderContent>{props.children}</SidebarFolderContent>
</SidebarFolder>
);
}
export function SidebarLinkItem({
item,
...props
}: {
item: Exclude<LinkItemType, { type: 'icon' }>;
className?: string;
}) {
if (item.type === 'menu')
return (
<SidebarFolder {...props}>
{item.url ? (
<SidebarFolderLink href={item.url} external={item.external}>
{item.icon}
{item.text}
</SidebarFolderLink>
) : (
<SidebarFolderTrigger>
{item.icon}
{item.text}
</SidebarFolderTrigger>
)}
<SidebarFolderContent>
{item.items.map((child, i) => (
<SidebarLinkItem key={i} item={child} />
))}
</SidebarFolderContent>
</SidebarFolder>
);
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
return (
<SidebarItem
href={item.url}
icon={item.icon}
external={item.external}
{...props}
>
{item.text}
</SidebarItem>
);
}
+173 -55
View File
@@ -1,9 +1,10 @@
'use client';
import { type ComponentProps, Fragment, useState } from 'react';
import { type ComponentProps, Fragment, useMemo, useState } from 'react';
import { cva } from 'class-variance-authority';
import Link from 'fumadocs-core/link';
import { cn } from '@/utils/cn';
import { BaseLinkItem, type LinkItemType } from '@/layouts/shared';
import { resolveLinkItems, type LinkItemType } from '@/layouts/shared';
import { LinkItem } from '@/layouts/shared/link-item';
import {
NavigationMenu,
NavigationMenuContent,
@@ -15,6 +16,17 @@ import {
} from '@/components/ui/navigation-menu';
import { useNav } from '@/contexts/layout';
import { buttonVariants } from '@/components/ui/button';
import type { HomeLayoutProps } from '.';
import {
LargeSearchToggle,
SearchToggle,
} from '@/layouts/shared/search-toggle';
import { ThemeToggle } from '@/layouts/shared/theme-toggle';
import {
LanguageToggle,
LanguageToggleText,
} from '@/layouts/shared/language-toggle';
import { ChevronDown, Languages } from 'lucide-react';
export const navItemVariants = cva('[&_svg]:size-4', {
variants: {
@@ -35,7 +47,144 @@ export const navItemVariants = cva('[&_svg]:size-4', {
},
});
export function Navbar(props: ComponentProps<'div'>) {
export function Header({
nav = {},
i18n = false,
links,
githubUrl,
themeSwitch = {},
searchToggle = {},
}: HomeLayoutProps) {
const { navItems, menuItems } = useMemo(() => {
const navItems: LinkItemType[] = [];
const menuItems: LinkItemType[] = [];
for (const item of resolveLinkItems({ links, githubUrl })) {
switch (item.on ?? 'all') {
case 'menu':
menuItems.push(item);
break;
case 'nav':
navItems.push(item);
break;
default:
navItems.push(item);
menuItems.push(item);
}
}
return { navItems, menuItems };
}, [links, githubUrl]);
return (
<HeaderNavigationMenu>
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-semibold"
>
{nav.title}
</Link>
{nav.children}
<ul className="flex flex-row items-center gap-2 px-6 max-sm:hidden">
{navItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<NavigationMenuLinkItem key={i} item={item} className="text-sm" />
))}
</ul>
<div className="flex flex-row items-center justify-end gap-1.5 flex-1 max-lg:hidden">
{searchToggle.enabled !== false &&
(searchToggle.components?.lg ?? (
<LargeSearchToggle
className="w-full rounded-full ps-2.5 max-w-[240px]"
hideIfDisabled
/>
))}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? <ThemeToggle mode={themeSwitch?.mode} />)}
{i18n && (
<LanguageToggle>
<Languages className="size-5" />
</LanguageToggle>
)}
<ul className="flex flex-row gap-2 items-center empty:hidden">
{navItems.filter(isSecondary).map((item, i) => (
<NavigationMenuLinkItem
key={i}
className={cn(
item.type === 'icon' && '-mx-1 first:ms-0 last:me-0',
)}
item={item}
/>
))}
</ul>
</div>
<ul className="flex flex-row items-center ms-auto -me-1.5 lg:hidden">
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="p-2" hideIfDisabled />
))}
<NavigationMenuItem>
<NavigationMenuTrigger
aria-label="Toggle Menu"
className={cn(
buttonVariants({
size: 'icon',
color: 'ghost',
className: 'group [&_svg]:size-5.5',
}),
)}
onPointerMove={
nav.enableHoverToOpen ? undefined : (e) => e.preventDefault()
}
>
<ChevronDown className="transition-transform duration-300 group-data-[state=open]:rotate-180" />
</NavigationMenuTrigger>
<NavigationMenuContent className="flex flex-col p-4 sm:flex-row sm:items-center sm:justify-end">
{menuItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<MobileNavigationMenuLinkItem
key={i}
item={item}
className="sm:hidden"
/>
))}
<div className="-ms-1.5 flex flex-row items-center gap-2 max-sm:mt-2">
{menuItems.filter(isSecondary).map((item, i) => (
<MobileNavigationMenuLinkItem
key={i}
item={item}
className={cn(item.type === 'icon' && '-mx-1 first:ms-0')}
/>
))}
<div role="separator" className="flex-1" />
{i18n && (
<LanguageToggle>
<Languages className="size-5" />
<LanguageToggleText />
<ChevronDown className="size-3 text-fd-muted-foreground" />
</LanguageToggle>
)}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle mode={themeSwitch?.mode} />
))}
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</ul>
</HeaderNavigationMenu>
);
}
function isSecondary(item: LinkItemType): boolean {
if ('secondary' in item && item.secondary != null) return item.secondary;
return item.type === 'icon';
}
function HeaderNavigationMenu(props: ComponentProps<'div'>) {
const [value, setValue] = useState('');
const { isTransparent } = useNav();
@@ -44,29 +193,30 @@ export function Navbar(props: ComponentProps<'div'>) {
<header
id="nd-nav"
{...props}
className={cn(
'fixed top-(--fd-banner-height) z-40 left-0 right-(--removed-body-scroll-bar-size,0) backdrop-blur-lg border-b transition-colors *:mx-auto *:max-w-fd-container',
value.length > 0 && 'max-lg:shadow-lg max-lg:rounded-b-2xl',
(!isTransparent || value.length > 0) && 'bg-fd-background/80',
props.className,
)}
className={cn('sticky h-14 top-0 z-40', props.className)}
>
<NavigationMenuList
className="flex h-14 w-full items-center px-4"
asChild
<div
className={cn(
'backdrop-blur-lg border-b transition-colors *:mx-auto *:max-w-fd-container',
value.length > 0 && 'max-lg:shadow-lg max-lg:rounded-b-2xl',
(!isTransparent || value.length > 0) && 'bg-fd-background/80',
)}
>
<nav>{props.children}</nav>
</NavigationMenuList>
<NavigationMenuList
className="flex h-14 w-full items-center px-4"
asChild
>
<nav>{props.children}</nav>
</NavigationMenuList>
<NavigationMenuViewport />
<NavigationMenuViewport />
</div>
</header>
</NavigationMenu>
);
}
export { NavigationMenuItem };
export function NavigationMenuLinkItem({
function NavigationMenuLinkItem({
item,
...props
}: {
@@ -136,19 +286,19 @@ export function NavigationMenuLinkItem({
return (
<NavigationMenuItem {...props}>
<NavigationMenuLink asChild>
<BaseLinkItem
<LinkItem
item={item}
aria-label={item.type === 'icon' ? item.label : undefined}
className={cn(navItemVariants({ variant: item.type }))}
>
{item.type === 'icon' ? item.icon : item.text}
</BaseLinkItem>
</LinkItem>
</NavigationMenuLink>
</NavigationMenuItem>
);
}
export function MobileNavigationMenuLinkItem({
function MobileNavigationMenuLinkItem({
item,
...props
}: {
@@ -188,7 +338,7 @@ export function MobileNavigationMenuLinkItem({
return (
<NavigationMenuLink asChild>
<BaseLinkItem
<LinkItem
item={item}
className={cn(
{
@@ -208,39 +358,7 @@ export function MobileNavigationMenuLinkItem({
>
{item.icon}
{item.type === 'icon' ? undefined : item.text}
</BaseLinkItem>
</LinkItem>
</NavigationMenuLink>
);
}
export function MobileNavigationMenuTrigger({
enableHover = false,
...props
}: ComponentProps<typeof NavigationMenuTrigger> & {
/**
* Enable hover to trigger
*/
enableHover?: boolean;
}) {
return (
<NavigationMenuTrigger
{...props}
onPointerMove={enableHover ? undefined : (e) => e.preventDefault()}
>
{props.children}
</NavigationMenuTrigger>
);
}
export function MobileNavigationMenuContent(
props: ComponentProps<typeof NavigationMenuContent>,
) {
return (
<NavigationMenuContent
{...props}
className={cn('flex flex-col p-4', props.className)}
>
{props.children}
</NavigationMenuContent>
);
}
+5 -157
View File
@@ -1,32 +1,8 @@
import { type HTMLAttributes, useMemo } from 'react';
import type { ComponentProps } from 'react';
import { cn } from '@/utils/cn';
import {
type BaseLayoutProps,
getLinks,
type LinkItemType,
type NavOptions,
} from '@/layouts/shared';
import { type BaseLayoutProps, type NavOptions } from '@/layouts/shared';
import { NavProvider } from '@/contexts/layout';
import {
LargeSearchToggle,
SearchToggle,
} from '@/components/layout/search-toggle';
import { ThemeToggle } from '@/components/layout/theme-toggle';
import {
LanguageToggle,
LanguageToggleText,
} from '@/components/layout/language-toggle';
import { ChevronDown, Languages } from 'lucide-react';
import Link from 'fumadocs-core/link';
import {
Navbar,
NavigationMenuLinkItem,
MobileNavigationMenuContent,
MobileNavigationMenuLinkItem,
MobileNavigationMenuTrigger,
NavigationMenuItem,
} from '@/layouts/home/client';
import { buttonVariants } from '@/components/ui/button';
import { Header } from '@/layouts/home/client';
export interface HomeLayoutProps extends BaseLayoutProps {
nav?: Partial<
@@ -39,9 +15,7 @@ export interface HomeLayoutProps extends BaseLayoutProps {
>;
}
export function HomeLayout(
props: HomeLayoutProps & HTMLAttributes<HTMLElement>,
) {
export function HomeLayout(props: HomeLayoutProps & ComponentProps<'main'>) {
const {
nav = {},
links,
@@ -57,7 +31,7 @@ export function HomeLayout(
<main
id="nd-home-layout"
{...rest}
className={cn('flex flex-1 flex-col pt-14', rest.className)}
className={cn('flex flex-1 flex-col', rest.className)}
>
{nav.enabled !== false &&
(nav.component ?? (
@@ -75,129 +49,3 @@ export function HomeLayout(
</NavProvider>
);
}
export function Header({
nav = {},
i18n = false,
links,
githubUrl,
themeSwitch = {},
searchToggle = {},
}: HomeLayoutProps) {
const finalLinks = useMemo(
() => getLinks(links, githubUrl),
[links, githubUrl],
);
const navItems = finalLinks.filter((item) =>
['nav', 'all'].includes(item.on ?? 'all'),
);
const menuItems = finalLinks.filter((item) =>
['menu', 'all'].includes(item.on ?? 'all'),
);
return (
<Navbar>
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-semibold"
>
{nav.title}
</Link>
{nav.children}
<ul className="flex flex-row items-center gap-2 px-6 max-sm:hidden">
{navItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<NavigationMenuLinkItem key={i} item={item} className="text-sm" />
))}
</ul>
<div className="flex flex-row items-center justify-end gap-1.5 flex-1 max-lg:hidden">
{searchToggle.enabled !== false &&
(searchToggle.components?.lg ?? (
<LargeSearchToggle
className="w-full rounded-full ps-2.5 max-w-[240px]"
hideIfDisabled
/>
))}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? <ThemeToggle mode={themeSwitch?.mode} />)}
{i18n && (
<LanguageToggle>
<Languages className="size-5" />
</LanguageToggle>
)}
<ul className="flex flex-row gap-2 items-center empty:hidden">
{navItems.filter(isSecondary).map((item, i) => (
<NavigationMenuLinkItem
key={i}
className={cn(
item.type === 'icon' && '-mx-1 first:ms-0 last:me-0',
)}
item={item}
/>
))}
</ul>
</div>
<ul className="flex flex-row items-center ms-auto -me-1.5 lg:hidden">
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="p-2" hideIfDisabled />
))}
<NavigationMenuItem>
<MobileNavigationMenuTrigger
aria-label="Toggle Menu"
className={cn(
buttonVariants({
size: 'icon',
color: 'ghost',
className: 'group [&_svg]:size-5.5',
}),
)}
enableHover={nav.enableHoverToOpen}
>
<ChevronDown className="transition-transform duration-300 group-data-[state=open]:rotate-180" />
</MobileNavigationMenuTrigger>
<MobileNavigationMenuContent className="sm:flex-row sm:items-center sm:justify-end">
{menuItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<MobileNavigationMenuLinkItem
key={i}
item={item}
className="sm:hidden"
/>
))}
<div className="-ms-1.5 flex flex-row items-center gap-2 max-sm:mt-2">
{menuItems.filter(isSecondary).map((item, i) => (
<MobileNavigationMenuLinkItem
key={i}
item={item}
className={cn(item.type === 'icon' && '-mx-1 first:ms-0')}
/>
))}
<div role="separator" className="flex-1" />
{i18n && (
<LanguageToggle>
<Languages className="size-5" />
<LanguageToggleText />
<ChevronDown className="size-3 text-fd-muted-foreground" />
</LanguageToggle>
)}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle mode={themeSwitch?.mode} />
))}
</div>
</MobileNavigationMenuContent>
</NavigationMenuItem>
</ul>
</Navbar>
);
}
function isSecondary(item: LinkItemType): boolean {
if ('secondary' in item && item.secondary != null) return item.secondary;
return item.type === 'icon';
}
+3 -3
View File
@@ -8,7 +8,7 @@ import { Sidebar as SidebarIcon } from 'lucide-react';
import Link from 'fumadocs-core/link';
import { usePathname } from 'fumadocs-core/framework';
import { isTabActive } from '@/utils/is-active';
import type { Option } from '@/components/layout/root-toggle';
import type { Option } from '@/layouts/shared/root-toggle';
export function Navbar({
mode,
@@ -40,10 +40,10 @@ export function LayoutBody(props: ComponentProps<'main'>) {
return (
<main
id="nd-docs-layout"
id="nd-notebook-layout"
{...props}
className={cn(
'flex flex-1 flex-col transition-[padding] pt-(--fd-nav-height) fd-notebook-layout',
'flex flex-1 flex-col transition-[padding] pt-(--fd-nav-height)',
!collapsed && 'mx-(--fd-layout-offset)',
props.className,
)}
+21 -26
View File
@@ -6,13 +6,7 @@ import {
type ReactNode,
useMemo,
} from 'react';
import {
type BaseLayoutProps,
BaseLinkItem,
type BaseLinkType,
getLinks,
type LinkItemType,
} from '@/layouts/shared';
import { type BaseLayoutProps, resolveLinkItems } from '@/layouts/shared';
import {
Sidebar,
SidebarCollapseTrigger,
@@ -30,7 +24,7 @@ import {
type SidebarProps,
SidebarTrigger,
SidebarViewport,
} from '@/components/layout/sidebar';
} from './sidebar';
import { TreeContextProvider } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
@@ -40,8 +34,8 @@ import {
Sidebar as SidebarIcon,
X,
} from 'lucide-react';
import { LanguageToggle } from '@/components/layout/language-toggle';
import { ThemeToggle } from '@/components/layout/theme-toggle';
import { LanguageToggle } from '@/layouts/shared/language-toggle';
import { ThemeToggle } from '@/layouts/shared/theme-toggle';
import {
Popover,
PopoverContent,
@@ -55,16 +49,17 @@ import {
NavbarSidebarTrigger,
} from '@/layouts/notebook/client';
import { NavProvider } from '@/contexts/layout';
import { type Option, RootToggle } from '@/components/layout/root-toggle';
import { type Option, RootToggle } from '@/layouts/shared/root-toggle';
import Link from 'fumadocs-core/link';
import {
LargeSearchToggle,
SearchToggle,
} from '@/components/layout/search-toggle';
} from '@/layouts/shared/search-toggle';
import {
getSidebarTabs,
type GetSidebarTabsOptions,
} from '@/utils/get-sidebar-tabs';
import { LinkItem, type LinkItemType } from '@/layouts/shared/link-item';
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
@@ -110,7 +105,7 @@ export function DocsLayout(props: DocsLayoutProps) {
} = props;
const navMode = nav.mode ?? 'auto';
const links = getLinks(props.links ?? [], props.githubUrl);
const links = resolveLinkItems(props);
const tabs = useMemo(() => {
if (Array.isArray(tabOptions)) {
return tabOptions;
@@ -229,7 +224,7 @@ export function DocsLayout(props: DocsLayoutProps) {
)}
>
{iconLinks.map((item, i) => (
<BaseLinkItem
<LinkItem
key={i}
item={item}
className={cn(
@@ -242,7 +237,7 @@ export function DocsLayout(props: DocsLayoutProps) {
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
</LinkItem>
))}
</Footer>
</SidebarContent>
@@ -273,7 +268,7 @@ export function DocsLayout(props: DocsLayoutProps) {
)}
>
{iconLinks.map((item, i) => (
<BaseLinkItem
<LinkItem
key={i}
item={item}
className={cn(
@@ -287,7 +282,7 @@ export function DocsLayout(props: DocsLayoutProps) {
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
</LinkItem>
))}
{i18n && (
<LanguageToggle>
@@ -353,8 +348,8 @@ function DocsNavbar({
<Navbar
mode={navMode}
className={cn(
'on-root:[--fd-nav-height:56px] md:on-root:[--fd-nav-height:64px]',
tabs.length > 0 && 'lg:on-root:[--fd-nav-height:104px]',
'on-notebook-layout:[--fd-nav-height:56px] md:on-notebook-layout:[--fd-nav-height:64px]',
tabs.length > 0 && 'lg:on-notebook-layout:[--fd-nav-height:104px]',
)}
>
<div
@@ -433,7 +428,7 @@ function DocsNavbar({
{links
.filter((item) => item.type === 'icon')
.map((item, i) => (
<BaseLinkItem
<LinkItem
key={i}
item={item}
className={cn(
@@ -443,7 +438,7 @@ function DocsNavbar({
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
</LinkItem>
))}
<div className="flex items-center md:hidden">
@@ -508,7 +503,7 @@ function NavbarLinkItem({
)}
>
{item.url ? (
<BaseLinkItem item={item as BaseLinkType}>{item.text}</BaseLinkItem>
<LinkItem item={item as { url: string }}>{item.text}</LinkItem>
) : (
item.text
)}
@@ -520,14 +515,14 @@ function NavbarLinkItem({
return <Fragment key={i}>{child.children}</Fragment>;
return (
<BaseLinkItem
<LinkItem
key={i}
item={child}
className="inline-flex items-center gap-2 rounded-md p-2 text-start hover:bg-fd-accent hover:text-fd-accent-foreground data-[active=true]:text-fd-primary [&_svg]:size-4"
>
{child.icon}
{child.text}
</BaseLinkItem>
</LinkItem>
);
})}
</PopoverContent>
@@ -538,9 +533,9 @@ function NavbarLinkItem({
if (item.type === 'custom') return item.children;
return (
<BaseLinkItem item={item} {...props}>
<LinkItem item={item} {...props}>
{item.text}
</BaseLinkItem>
</LinkItem>
);
}
@@ -30,7 +30,7 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { useSidebar } from '@/contexts/sidebar';
import { useTOCItems } from '@/components/layout/toc';
import { useTOCItems } from '@/components/toc';
import { useActiveAnchor } from 'fumadocs-core/toc';
const TocPopoverContext = createContext<{
@@ -199,7 +199,7 @@ export function PageTOCPopover(props: ComponentProps<'div'>) {
id="nd-tocnav"
{...props}
className={cn(
'fixed pr-(--removed-body-scroll-bar-size,0) z-10 border-b backdrop-blur-sm transition-colors xl:hidden max-xl:on-root:[--fd-tocnav-height:40px]',
'fixed pr-(--removed-body-scroll-bar-size,0) z-10 border-b backdrop-blur-sm transition-colors xl:hidden max-xl:on-notebook-layout:[--fd-tocnav-height:40px]',
(!isTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
props.className,
@@ -402,7 +402,7 @@ export function PageTOC(props: ComponentProps<'div'>) {
id="nd-toc"
{...props}
className={cn(
'fixed bottom-0 pt-12 pb-2 pr-(--removed-body-scroll-bar-size,0) xl:on-root:[--fd-toc-width:286px] max-xl:hidden',
'fixed bottom-0 pt-12 pb-2 pr-(--removed-body-scroll-bar-size,0) max-xl:hidden',
props.className,
)}
style={{
@@ -3,23 +3,22 @@ import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { Edit } from '@/icons';
import { I18nLabel } from '@/contexts/i18n';
import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
import { TOCProvider, TOCScrollArea } from '@/components/toc';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { Text } from 'lucide-react';
import {
type BreadcrumbProps,
type FooterProps,
PageArticle,
BreadcrumbProps,
FooterProps,
PageBreadcrumb,
PageFooter,
PageLastUpdate,
PageRoot,
PageTOC,
PageTOCItems,
PageTOCPopover,
PageTOCPopoverContent,
PageTOCPopoverItems,
PageTOCPopoverTrigger,
PageTOCTitle,
} from '@/layouts/docs/page';
import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
} from './client';
interface EditOnGitHubOptions
extends Omit<ComponentProps<'a'>, 'href' | 'children'> {
@@ -138,17 +137,24 @@ export function DocsPage({
tocPopoverOptions.header !== undefined ||
tocPopoverOptions.footer !== undefined;
return (
<PageRoot
toc={
tocEnabled || tocPopoverEnabled
? {
toc,
single: tocOptions.single,
}
: false
}
let wrapper = (children: ReactNode) => children;
if (tocEnabled || tocPopoverEnabled) {
wrapper = (children) => (
<TOCProvider toc={toc} single={tocOptions.single}>
{children}
</TOCProvider>
);
}
return wrapper(
<div
id="nd-page"
{...container}
className={cn(
'flex flex-1 w-full mx-auto max-w-(--fd-page-width) pt-(--fd-tocnav-height) pe-(--fd-toc-width)',
container?.className,
)}
>
{tocPopoverEnabled &&
(tocPopover ?? (
@@ -156,12 +162,24 @@ export function DocsPage({
<PageTOCPopoverTrigger />
<PageTOCPopoverContent>
{tocPopoverOptions.header}
<PageTOCPopoverItems variant={tocPopoverOptions.style} />
<TOCScrollArea>
{tocPopoverOptions.style === 'clerk' ? (
<TocDefault.TOCItems />
) : (
<TocClerk.TOCItems />
)}
</TOCScrollArea>
{tocPopoverOptions.footer}
</PageTOCPopoverContent>
</PageTOCPopover>
))}
<PageArticle {...article}>
<article
{...article}
className={cn(
'flex min-w-0 w-full flex-col gap-4 pt-8 px-4 md:px-6 md:mx-auto',
article?.className,
)}
>
{breadcrumbEnabled &&
(breadcrumb ?? <PageBreadcrumb {...breadcrumbProps} />)}
{children}
@@ -175,17 +193,29 @@ export function DocsPage({
</div>
{footer.enabled !== false &&
(footer.component ?? <PageFooter items={footer.items} />)}
</PageArticle>
</article>
{tocEnabled &&
(tocReplace ?? (
<PageTOC>
{tocOptions.header}
<PageTOCTitle />
<PageTOCItems variant={tocOptions.style} />
<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' ? (
<TocDefault.TOCItems />
) : (
<TocClerk.TOCItems />
)}
</TOCScrollArea>
{tocOptions.footer}
</PageTOC>
))}
</PageRoot>
</div>,
);
}
@@ -7,7 +7,7 @@ import {
type FC,
Fragment,
type ReactNode,
useContext,
use,
useMemo,
useRef,
useState,
@@ -441,14 +441,14 @@ export function SidebarCollapseTrigger(props: ComponentProps<'button'>) {
}
function useFolderContext() {
const ctx = useContext(FolderContext);
const ctx = use(FolderContext);
if (!ctx) throw new Error('Missing sidebar folder');
return ctx;
}
function useInternalContext() {
const ctx = useContext(Context);
const ctx = use(Context);
if (!ctx) throw new Error('<Sidebar /> component required.');
return ctx;
-30
View File
@@ -1,30 +0,0 @@
'use client';
import type { ComponentProps } from 'react';
import { usePathname } from 'fumadocs-core/framework';
import { isActive } from '@/utils/is-active';
import Link from 'fumadocs-core/link';
import type { BaseLinkType } from '@/layouts/shared';
export function BaseLinkItem({
ref,
item,
...props
}: Omit<ComponentProps<'a'>, 'href'> & { item: BaseLinkType }) {
const pathname = usePathname();
const activeType = item.active ?? 'url';
const active =
activeType !== 'none' &&
isActive(item.url, pathname, activeType === 'nested-url');
return (
<Link
ref={ref}
href={item.url}
external={item.external}
{...props}
data-active={active}
>
{props.children}
</Link>
);
}
+21 -113
View File
@@ -1,6 +1,7 @@
import type { HTMLAttributes, ReactNode } from 'react';
import type { ReactNode } from 'react';
import type { NavProviderProps } from '@/contexts/layout';
import type { I18nConfig } from 'fumadocs-core/i18n';
import type { LinkItemType } from './link-item';
export interface NavOptions extends NavProviderProps {
enabled: boolean;
@@ -53,123 +54,30 @@ export interface BaseLayoutProps {
children?: ReactNode;
}
interface BaseItem {
/**
* Restrict where the item is displayed
*
* @defaultValue 'all'
*/
on?: 'menu' | 'nav' | 'all';
}
export interface BaseLinkType extends BaseItem {
url: string;
/**
* When the item is marked as active
*
* @defaultValue 'url'
*/
active?: 'url' | 'nested-url' | 'none';
external?: boolean;
}
export interface MainItemType extends BaseLinkType {
type?: 'main';
icon?: ReactNode;
text: ReactNode;
description?: ReactNode;
}
export interface IconItemType extends BaseLinkType {
type: 'icon';
/**
* `aria-label` of icon button
*/
label?: string;
icon: ReactNode;
text: ReactNode;
/**
* @defaultValue true
*/
secondary?: boolean;
}
export interface ButtonItemType extends BaseLinkType {
type: 'button';
icon?: ReactNode;
text: ReactNode;
/**
* @defaultValue false
*/
secondary?: boolean;
}
export interface MenuItemType extends Partial<BaseLinkType> {
type: 'menu';
icon?: ReactNode;
text: ReactNode;
items: (
| (MainItemType & {
/**
* Options when displayed on navigation menu
*/
menu?: HTMLAttributes<HTMLElement> & {
banner?: ReactNode;
};
})
| CustomItemType
)[];
/**
* @defaultValue false
*/
secondary?: boolean;
}
export interface CustomItemType extends BaseItem {
type: 'custom';
/**
* @defaultValue false
*/
secondary?: boolean;
children: ReactNode;
}
export type LinkItemType =
| MainItemType
| IconItemType
| ButtonItemType
| MenuItemType
| CustomItemType;
/**
* Get Links Items with shortcuts
* Get link items with shortcuts
*/
export function getLinks(
links: LinkItemType[] = [],
githubUrl?: string,
): LinkItemType[] {
let result = links ?? [];
export function resolveLinkItems({
links = [],
githubUrl,
}: Pick<BaseLayoutProps, 'links' | 'githubUrl'>): LinkItemType[] {
const result = [...links];
if (githubUrl)
result = [
...result,
{
type: 'icon',
url: githubUrl,
text: 'Github',
label: 'GitHub',
icon: (
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
),
external: true,
},
];
result.push({
type: 'icon',
url: githubUrl,
text: 'Github',
label: 'GitHub',
icon: (
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
),
external: true,
});
return result;
}
export { BaseLinkItem } from './client';
export type * from './link-item';
@@ -1,5 +1,5 @@
'use client';
import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react';
import type { ComponentProps } from 'react';
import { useI18n } from '@/contexts/i18n';
import {
Popover,
@@ -9,7 +9,7 @@ import {
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
export type LanguageSelectProps = ButtonHTMLAttributes<HTMLButtonElement>;
export type LanguageSelectProps = ComponentProps<'button'>;
export function LanguageToggle(props: LanguageSelectProps): React.ReactElement {
const context = useI18n();
@@ -56,9 +56,7 @@ export function LanguageToggle(props: LanguageSelectProps): React.ReactElement {
);
}
export function LanguageToggleText(
props: HTMLAttributes<HTMLSpanElement>,
): React.ReactElement {
export function LanguageToggleText(props: ComponentProps<'span'>) {
const context = useI18n();
const text = context.locales?.find(
(item) => item.locale === context.locale,
@@ -0,0 +1,119 @@
'use client';
import type { ComponentProps, ReactNode } from 'react';
import { usePathname } from 'fumadocs-core/framework';
import { isActive } from '@/utils/is-active';
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: WithHref }) {
const pathname = usePathname();
const activeType = item.active ?? 'url';
const active =
activeType !== 'none' &&
isActive(item.url, pathname, activeType === 'nested-url');
return (
<Link
ref={ref}
href={item.url}
external={item.external}
{...props}
data-active={active}
>
{props.children}
</Link>
);
}
@@ -6,7 +6,11 @@ import { usePathname } from 'fumadocs-core/framework';
import { cn } from '@/utils/cn';
import { isTabActive } from '@/utils/is-active';
import { useSidebar } from '@/contexts/sidebar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { SidebarTab } from '@/utils/get-sidebar-tabs';
export interface Option extends SidebarTab {
@@ -2,7 +2,7 @@
import { cva } from 'class-variance-authority';
import { Moon, Sun, Airplay } from 'lucide-react';
import { useTheme } from 'next-themes';
import { type HTMLAttributes, useLayoutEffect, useState } from 'react';
import { ComponentProps, useEffect, useState } from 'react';
import { cn } from '@/utils/cn';
const itemVariants = cva(
@@ -27,13 +27,13 @@ export function ThemeToggle({
className,
mode = 'light-dark',
...props
}: HTMLAttributes<HTMLElement> & {
}: ComponentProps<'div'> & {
mode?: 'light-dark' | 'light-dark-system';
}) {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
useEffect(() => {
setMounted(true);
}, []);
@@ -51,7 +51,6 @@ export function ThemeToggle({
aria-label={`Toggle Theme`}
onClick={() => setTheme(value === 'light' ? 'dark' : 'light')}
data-theme-toggle=""
{...props}
>
{full.map(([key, Icon]) => {
if (key === 'system') return;