UI: Move TOC closer to page body on larger viewports

This commit is contained in:
Fuma Nama
2025-05-28 23:22:01 +08:00
parent 9857b95edd
commit 589d1011d1
12 changed files with 162 additions and 120 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'fumadocs-ui': minor
---
**Move TOC closer to page body on larger viewports**
Note that this may impact sites using custom styling.
For Fumadocs CLI users: you can re-install the layout components again to get the latest look.
+2 -2
View File
@@ -54,7 +54,7 @@ export function LLMCopyButton({ slug }: { slug: string[] }) {
);
}
export function EditOnGitHub({ url }: { url: string }) {
export function GitHubLink({ url }: { url: string }) {
return (
<Link
className={cn(
@@ -75,7 +75,7 @@ export function EditOnGitHub({ url }: { url: string }) {
<title>GitHub</title>
<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>
Edit on GitHub
View on GitHub
</Link>
);
}
+3 -3
View File
@@ -35,7 +35,7 @@ import { getPageTreePeers } from 'fumadocs-core/server';
import { Card, Cards } from 'fumadocs-ui/components/card';
import { getMDXComponents } from '@/mdx-components';
import { APIPage } from 'fumadocs-openapi/ui';
import { EditOnGitHub, LLMCopyButton } from './page.client';
import { GitHubLink, LLMCopyButton } from './page.client';
function PreviewRenderer({ preview }: { preview: string }): ReactNode {
if (preview && preview in Preview) {
@@ -79,9 +79,9 @@ export default async function Page(props: {
<DocsDescription className="mb-0">
{page.data.description}
</DocsDescription>
<div className="flex flex-row gap-2 items-center mb-4">
<div className="flex flex-row gap-2 items-center mb-8 border-b pb-6">
<LLMCopyButton slug={params.slug} />
<EditOnGitHub
<GitHubLink
url={`https://github.com/${owner}/${repo}/blob/dev/${path}`}
/>
</div>
+6
View File
@@ -5,6 +5,7 @@
@theme {
--spacing-fd-container: 1400px;
--fd-page-width: 1200px;
--fd-sidebar-width: 0px;
--fd-toc-width: 0px;
--fd-layout-width: 100vw;
@@ -242,6 +243,11 @@
[data-rmiz-modal-overlay='visible'] {
background-color: var(--color-fd-background);
}
:root,
#nd-docs-layout {
--fd-layout-offset: max(calc(50vw - var(--fd-layout-width) / 2), 0px);
}
}
@utility fd-scroll-container {
@@ -150,7 +150,7 @@ export function Sidebar({
{...props}
data-collapsed={collapsed}
className={cn(
'sticky shrink-0 flex flex-col items-end top-(--fd-sidebar-top) z-20 bg-fd-card text-sm h-(--fd-sidebar-height) w-(--fd-sidebar-width) *:w-(--fd-sidebar-width) border-e max-md:hidden',
'fixed start-0 flex flex-col items-end top-(--fd-sidebar-top) bottom-(--fd-sidebar-margin) z-20 bg-fd-card text-sm border-e max-md:hidden *:w-(--fd-sidebar-width)',
collapsed && [
'rounded-xl border',
hover
@@ -161,15 +161,16 @@ export function Sidebar({
)}
style={
{
transition: ['top', 'opacity', 'margin', 'translate', 'width']
.map((v) => `${v} ease 200ms`)
transition: ['top', 'opacity', 'translate', 'width']
.map((v) => `${v} ease 250ms`)
.join(', '),
...props.style,
'--fd-sidebar-offset': 'calc(100% - 16px)',
'--fd-sidebar-margin': collapsed ? '0.5rem' : '0px',
width: collapsed
? 'var(--fd-sidebar-width)'
: 'calc(var(--fd-sidebar-width) + var(--fd-layout-offset))',
'--fd-sidebar-top': `calc(var(--fd-banner-height) + var(--fd-nav-height) + var(--fd-sidebar-margin))`,
'--fd-sidebar-height':
'calc(100dvh - var(--fd-sidebar-top) - var(--fd-sidebar-margin))',
...props.style,
} as object
}
onPointerEnter={(e) => {
+23
View File
@@ -28,6 +28,29 @@ export function Navbar(props: ComponentProps<'header'>) {
);
}
export function LayoutBody(props: ComponentProps<'main'>) {
const { collapsed } = useSidebar();
return (
<main
id="nd-docs-layout"
{...props}
className={cn(
'flex flex-1 flex-col transition-[margin]',
props.className,
)}
style={{
...props.style,
marginInlineStart: collapsed
? 'max(0px, min(calc(100vw - var(--fd-page-width)), var(--fd-sidebar-width)))'
: 'var(--fd-sidebar-width)',
}}
>
{props.children}
</main>
);
}
export function NavbarSidebarTrigger({
className,
...props
+5 -21
View File
@@ -25,6 +25,7 @@ import {
} from '@/components/layout/language-toggle';
import {
CollapsibleControl,
LayoutBody,
Navbar,
NavbarSidebarTrigger,
} from '@/layouts/docs-client';
@@ -32,7 +33,6 @@ import { TreeContextProvider } from '@/contexts/tree';
import { ThemeToggle } from '@/components/layout/theme-toggle';
import {
getSidebarTabsFromOptions,
layoutVariables,
SidebarLinkItem,
type SidebarOptions,
} from '@/layouts/docs/shared';
@@ -111,18 +111,9 @@ export function DocsLayout({
<NavbarSidebarTrigger className="p-2 -me-1.5 md:hidden" />
</Navbar>,
)}
<main
id="nd-docs-layout"
<LayoutBody
{...props.containerProps}
className={cn(
'flex flex-1 flex-row pe-(--fd-layout-offset)',
variables,
props.containerProps?.className,
)}
style={{
...layoutVariables,
...props.containerProps?.style,
}}
className={cn(variables, props.containerProps?.className)}
>
{slot(
sidebar,
@@ -167,7 +158,7 @@ export function DocsLayout({
/>,
)}
<StylesProvider {...pageStyles}>{children}</StylesProvider>
</main>
</LayoutBody>
</NavProvider>
</TreeContextProvider>
);
@@ -188,14 +179,7 @@ export function DocsLayoutSidebar({
return (
<>
{collapsible ? <CollapsibleControl /> : null}
<Sidebar
{...props}
collapsible={collapsible}
className={cn(
'data-[collapsed=false]:w-[calc(var(--fd-sidebar-width)+var(--fd-layout-offset))] data-[collapsed=true]:me-[calc(var(--fd-layout-offset)-var(--fd-sidebar-width))]',
props.className,
)}
>
<Sidebar {...props} collapsible={collapsible}>
<HideIfEmpty>
<SidebarHeader>
<div className="flex max-md:hidden">
-4
View File
@@ -16,10 +16,6 @@ import {
type GetSidebarTabsOptions,
} from '@/utils/get-sidebar-tabs';
export const layoutVariables = {
'--fd-layout-offset': 'max(calc(50vw - var(--fd-layout-width) / 2), 0px)',
};
export interface SidebarOptions extends SidebarProps {
components?: Partial<SidebarComponents>;
+31 -13
View File
@@ -1,6 +1,6 @@
'use client';
import { cn } from '@/utils/cn';
import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react';
import { type ComponentProps } from 'react';
import { useSidebar } from '@/contexts/sidebar';
import { useNav } from '@/contexts/layout';
import { buttonVariants } from '@/components/ui/button';
@@ -13,7 +13,7 @@ import type { Option } from '@/components/layout/root-toggle';
export function Navbar({
mode,
...props
}: HTMLAttributes<HTMLElement> & { mode: 'top' | 'auto' }) {
}: ComponentProps<'header'> & { mode: 'top' | 'auto' }) {
const { open, collapsed } = useSidebar();
const { isTransparent } = useNav();
@@ -35,10 +35,33 @@ export function Navbar({
);
}
export function LayoutBody(props: ComponentProps<'main'>) {
const { collapsed } = useSidebar();
return (
<main
id="nd-docs-layout"
{...props}
className={cn(
'flex flex-1 flex-col transition-[margin]',
props.className,
)}
style={{
...props.style,
marginInlineStart: collapsed
? 'max(0px, min(calc(100vw - var(--fd-page-width)), var(--fd-sidebar-width)))'
: 'var(--fd-sidebar-width)',
}}
>
{props.children}
</main>
);
}
export function NavbarSidebarTrigger({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
}: ComponentProps<'button'>) {
const { setOpen } = useSidebar();
return (
@@ -58,7 +81,7 @@ export function NavbarSidebarTrigger({
);
}
export function LayoutTabs(props: HTMLAttributes<HTMLElement>) {
export function LayoutTabs(props: ComponentProps<'div'>) {
return (
<div
{...props}
@@ -72,17 +95,12 @@ export function LayoutTabs(props: HTMLAttributes<HTMLElement>) {
);
}
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);
const pathname = usePathname();
const selected = item.urls
? item.urls.has(pathname.endsWith('/') ? pathname.slice(0, -1) : pathname)
: isActive(item.url, pathname, true);
return (
<Link
+4 -14
View File
@@ -22,12 +22,12 @@ import {
} from '@/components/ui/popover';
import {
getSidebarTabsFromOptions,
layoutVariables,
SidebarLinkItem,
type SidebarOptions,
} from '@/layouts/docs/shared';
import type { PageTree } from 'fumadocs-core/server';
import {
LayoutBody,
LayoutTab,
LayoutTabs,
Navbar,
@@ -120,23 +120,13 @@ export function DocsLayout(props: DocsLayoutProps) {
return (
<TreeContextProvider tree={props.tree}>
<NavProvider transparentMode={transparentMode}>
<main
id="nd-docs-layout"
<LayoutBody
{...props.containerProps}
className={cn(
'flex w-full flex-1 flex-row pe-(--fd-layout-offset)',
variables,
props.containerProps?.className,
)}
style={{
...layoutVariables,
...props.containerProps?.style,
}}
className={cn(variables, props.containerProps?.className)}
>
<Sidebar
{...sidebar}
className={cn(
'data-[collapsed=false]:w-[calc(var(--fd-sidebar-width)+var(--fd-layout-offset))] data-[collapsed=true]:me-[calc(var(--fd-layout-offset)-var(--fd-sidebar-width))]',
navMode === 'top'
? 'md:bg-transparent'
: 'md:[--fd-nav-height:0px]',
@@ -216,7 +206,7 @@ export function DocsLayout(props: DocsLayoutProps) {
tabs={tabMode == 'navbar' ? tabs : []}
/>
<StylesProvider {...pageStyles}>{props.children}</StylesProvider>
</main>
</LayoutBody>
</NavProvider>
</TreeContextProvider>
);
+26 -6
View File
@@ -28,6 +28,7 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import * as Primitive from 'fumadocs-core/toc';
import { useSidebar } from '@/contexts/sidebar';
const TocPopoverContext = createContext<{
open: boolean;
@@ -160,6 +161,7 @@ export function TocPopoverContent(props: ComponentProps<'div'>) {
export function TocPopover(props: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { collapsed } = useSidebar();
const { tocNav } = usePageStyles();
const { isTransparent } = useNav();
@@ -181,10 +183,16 @@ export function TocPopover(props: ComponentProps<'div'>) {
return (
<div
{...props}
className={cn('sticky overflow-visible z-10', tocNav, props.className)}
className={cn(
'fixed inset-x-0 top-[calc(var(--fd-banner-height)+var(--fd-nav-height))] overflow-visible z-10',
tocNav,
props.className,
)}
style={{
...props.style,
top: 'calc(var(--fd-banner-height) + var(--fd-nav-height))',
insetInlineStart: collapsed
? '0px'
: 'calc(var(--fd-sidebar-width) + var(--fd-layout-offset))',
}}
>
<TocPopoverContext.Provider
@@ -215,16 +223,28 @@ export function TocPopover(props: ComponentProps<'div'>) {
);
}
export function PageBody(props: ComponentProps<'div'>) {
export function PageBody({
className,
style,
children,
...props
}: ComponentProps<'div'>) {
const { page } = usePageStyles();
const { collapsed } = useSidebar();
return (
<div
id="nd-page"
{...props}
className={cn('flex w-full min-w-0 flex-col', page, props.className)}
className={cn(className, page)}
style={{
maxWidth: collapsed
? 'var(--fd-page-width)'
: 'min(var(--fd-page-width),calc(var(--fd-layout-width) - var(--fd-sidebar-width)))',
...style,
}}
>
{props.children}
{children}
</div>
);
}
@@ -236,7 +256,7 @@ export function PageArticle(props: ComponentProps<'article'>) {
<article
{...props}
className={cn(
'flex w-full flex-1 flex-col gap-6 px-4 md:px-6 pt-8 md:pt-12 xl:px-12 xl:mx-auto',
'flex min-w-0 w-full flex-col gap-6 px-4 pt-8 md:px-6 md:pt-12 xl:px-12 xl:mx-auto',
article,
props.className,
)}
+46 -51
View File
@@ -138,9 +138,31 @@ export function DocsPage({
return (
<AnchorProvider toc={toc} single={tocOptions.single}>
{slot(
{ enabled: tocPopoverEnabled, component: tocPopoverReplace },
<TocPopover className="h-10">
<TocPopoverTrigger className="w-full" items={toc} />
<TocPopoverContent>
{tocPopoverOptions.header}
<TOCScrollArea className="px-4 md:px-6">
{tocPopoverOptions.style === 'clerk' ? (
<ClerkTOCItems items={toc} />
) : (
<TOCItems items={toc} />
)}
</TOCScrollArea>
{tocPopoverOptions.footer}
</TocPopoverContent>
</TocPopover>,
{
items: toc,
...tocPopoverOptions,
},
)}
<PageBody
{...props.container}
className={cn(props.container?.className)}
className={cn('flex flex-1 mx-auto w-full', props.container?.className)}
style={
{
'--fd-tocnav-height': !tocPopoverEnabled ? '0px' : undefined,
@@ -148,34 +170,7 @@ export function DocsPage({
} as object
}
>
{slot(
{ enabled: tocPopoverEnabled, component: tocPopoverReplace },
<TocPopover className="h-10">
<TocPopoverTrigger className="w-full" items={toc} />
<TocPopoverContent>
{tocPopoverOptions.header}
<TOCScrollArea className="px-4 md:px-6">
{tocPopoverOptions.style === 'clerk' ? (
<ClerkTOCItems items={toc} />
) : (
<TOCItems items={toc} />
)}
</TOCScrollArea>
{tocPopoverOptions.footer}
</TocPopoverContent>
</TocPopover>,
{
items: toc,
...tocPopoverOptions,
},
)}
<PageArticle
{...props.article}
className={cn(
full || !tocEnabled ? 'max-w-[1120px]' : 'max-w-[860px]',
props.article?.className,
)}
>
<PageArticle {...props.article}>
{slot(props.breadcrumb, <Breadcrumb {...props.breadcrumb} />)}
{props.children}
<div role="none" className="flex-1" />
@@ -191,29 +186,29 @@ export function DocsPage({
</div>
{slot(props.footer, <Footer items={props.footer?.items} />)}
</PageArticle>
{slot(
{ enabled: tocEnabled, component: tocReplace },
<Toc>
{tocOptions.header}
<h3 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' ? (
<ClerkTOCItems items={toc} />
) : (
<TOCItems items={toc} />
)}
</TOCScrollArea>
{tocOptions.footer}
</Toc>,
{
items: toc,
...tocOptions,
},
)}
</PageBody>
{slot(
{ enabled: tocEnabled, component: tocReplace },
<Toc>
{tocOptions.header}
<h3 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' ? (
<ClerkTOCItems items={toc} />
) : (
<TOCItems items={toc} />
)}
</TOCScrollArea>
{tocOptions.footer}
</Toc>,
{
items: toc,
...tocOptions,
},
)}
</AnchorProvider>
);
}