UI: move certain logic from page to layouts

This commit is contained in:
Fuma Nama
2024-11-26 19:49:26 +08:00
parent b4179f6609
commit 06f66d8e16
17 changed files with 221 additions and 147 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
improve notebook layout for transparent sidebar
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>
+3
View File
@@ -63,6 +63,9 @@ export default async function Page(props: {
sha: 'dev',
path,
}}
article={{
className: 'max-sm:pb-16',
}}
>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
+1 -1
View File
@@ -1,4 +1,4 @@
import { DocsLayout, type DocsLayoutProps } from 'fumadocs-ui/layouts/docs';
import { DocsLayout, type DocsLayoutProps } from 'fumadocs-ui/layouts/notebook';
import type { ReactNode } from 'react';
import { MessageCircle } from 'lucide-react';
import { Slot } from '@radix-ui/react-slot';
+5
View File
@@ -15,11 +15,16 @@
}
:root {
--fd-layout-width: 1400px;
--headless-color: 250 80% 54%;
--mdx-color: 0 0% 9%;
--ui-color: 220deg 91% 54%;
}
body {
overscroll-behavior-y: none;
}
.dark {
--headless-color: 250 100% 80%;
--mdx-color: 0 0% 100%;
+1 -1
View File
@@ -5,5 +5,5 @@
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist", "eslint.config.mjs"]
"exclude": ["node_modules", "dist", "eslint.config.mjs", "tailwind.config.js"]
}
+3 -2
View File
@@ -76,7 +76,7 @@ export function SearchDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogOverlay className="fixed inset-0 z-50 bg-fd-background/50 backdrop-blur-sm data-[state=closed]:animate-fd-fade-out data-[state=open]:animate-fd-fade-in" />
<DialogOverlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur-sm data-[state=closed]:animate-fd-fade-out data-[state=open]:animate-fd-fade-in" />
<DialogContent
aria-describedby={undefined}
className="fixed left-1/2 top-[10vh] z-50 w-[98vw] max-w-screen-sm origin-left -translate-x-1/2 rounded-lg border bg-fd-popover text-fd-popover-foreground shadow-lg data-[state=closed]:animate-fd-dialog-out data-[state=open]:animate-fd-dialog-in"
@@ -274,7 +274,8 @@ function CommandItem({
onPointerMove={() => onActiveChange(value)}
{...props}
className={cn(
'flex min-h-10 select-none flex-row items-center gap-2.5 rounded-lg px-2 text-start text-sm aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-selected:bg-fd-accent aria-selected:text-fd-accent-foreground',
'flex min-h-10 select-none flex-row items-center gap-2.5 rounded-lg px-2 text-start text-sm',
active === value && 'bg-fd-accent text-fd-accent-foreground',
props.className,
)}
>
@@ -1,67 +0,0 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/utils/cn';
import type { TOCItemType } from 'fumadocs-core/server';
import { useI18n } from '@/contexts/i18n';
import * as Primitive from 'fumadocs-core/toc';
import { ChevronRight, Text } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type {
PopoverContentProps,
PopoverTriggerProps,
} from '@radix-ui/react-popover';
export const TocPopover = Popover;
export function TocPopoverTrigger({
items,
...props
}: PopoverTriggerProps & { items: TOCItemType[] }) {
const { text } = useI18n();
const active = Primitive.useActiveAnchor();
const current = useMemo(() => {
return items.find((item) => active === item.url.slice(1))?.title;
}, [items, active]);
return (
<PopoverTrigger
{...props}
className={cn(
'inline-flex items-center gap-2 text-nowrap px-4 py-2 text-start',
props.className,
)}
>
<Text className="size-4 shrink-0" />
{text.toc}
{current ? (
<>
<ChevronRight className="-mx-1.5 size-4 shrink-0 text-fd-muted-foreground" />
<span className="truncate text-fd-muted-foreground">{current}</span>
</>
) : null}
</PopoverTrigger>
);
}
export function TocPopoverContent(props: PopoverContentProps) {
return (
<PopoverContent
hideWhenDetached
alignOffset={16}
align="start"
side="bottom"
data-toc-popover=""
{...props}
className={cn(
'flex max-h-[var(--radix-popover-content-available-height)] w-[260px] flex-col gap-4 p-3',
props.className,
)}
>
{props.children}
</PopoverContent>
);
}
+62 -1
View File
@@ -1,11 +1,21 @@
'use client';
import type { TOCItemType } from 'fumadocs-core/server';
import * as Primitive from 'fumadocs-core/toc';
import { type HTMLAttributes, type ReactNode, useRef } from 'react';
import { type HTMLAttributes, type ReactNode, useMemo, useRef } from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TocThumb } from '@/components/layout/toc-thumb';
import { ScrollArea, ScrollViewport } from '../ui/scroll-area';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type {
PopoverContentProps,
PopoverTriggerProps,
} from '@radix-ui/react-popover';
import { ChevronRight, Text } from 'lucide-react';
export interface TOCProps {
/**
@@ -105,3 +115,54 @@ function TOCItem({ item }: { item: TOCItemType }) {
</Primitive.TOCItem>
);
}
export const TocPopover = Popover;
export function TocPopoverTrigger({
items,
...props
}: PopoverTriggerProps & { items: TOCItemType[] }) {
const { text } = useI18n();
const active = Primitive.useActiveAnchor();
const current = useMemo(() => {
return items.find((item) => active === item.url.slice(1))?.title;
}, [items, active]);
return (
<PopoverTrigger
{...props}
className={cn(
'inline-flex items-center gap-2 text-nowrap px-4 py-2 text-start',
props.className,
)}
>
<Text className="size-4 shrink-0" />
{text.toc}
{current ? (
<>
<ChevronRight className="-mx-1.5 size-4 shrink-0 text-fd-muted-foreground" />
<span className="truncate text-fd-muted-foreground">{current}</span>
</>
) : null}
</PopoverTrigger>
);
}
export function TocPopoverContent(props: PopoverContentProps) {
return (
<PopoverContent
hideWhenDetached
alignOffset={16}
align="start"
side="bottom"
data-toc-popover=""
{...props}
className={cn(
'flex max-h-[var(--radix-popover-content-available-height)] w-[260px] flex-col gap-4 p-3',
props.className,
)}
>
{props.children}
</PopoverContent>
);
}
+23 -2
View File
@@ -22,6 +22,27 @@ import {
} from '@/components/ui/collapsible';
import { cva } from 'class-variance-authority';
import { buttonVariants } from '@/components/ui/button';
import { useSidebar } from '@/contexts/sidebar';
export function LayoutBody(props: HTMLAttributes<HTMLElement>) {
const { collapsed } = useSidebar();
return (
<main
{...props}
style={
{
'--fd-page-width': collapsed
? '100vw'
: 'calc(min(100vw, var(--fd-layout-width)) - var(--fd-sidebar-width) - var(--fd-toc-width))',
...props.style,
} as object
}
>
{props.children}
</main>
);
}
const itemVariants = cva(
'flex flex-row items-center gap-2 rounded-md px-3 py-2.5 text-fd-muted-foreground transition-colors duration-100 [overflow-wrap:anywhere] hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none md:px-2 md:py-1.5 [&_svg]:size-4',
@@ -68,11 +89,11 @@ export function MenuItem({ item, ...props }: MenuItemProps) {
<Collapsible className="flex flex-col">
<CollapsibleTrigger
{...props}
className={cn(itemVariants(), 'group/link', props.className)}
className={cn(itemVariants(), 'group', props.className)}
>
{item.icon}
{item.text}
<ChevronDown className="ms-auto transition-transform group-data-[state=closed]/link:-rotate-90" />
<ChevronDown className="ms-auto transition-transform group-data-[state=closed]:-rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col py-2 ps-2">
+7 -4
View File
@@ -27,7 +27,7 @@ import {
LanguageToggle,
LanguageToggleText,
} from '@/components/layout/language-toggle';
import { LinksMenu } from '@/layouts/docs.client';
import { LayoutBody, LinksMenu } from '@/layouts/docs.client';
import { TreeContextProvider } from '@/contexts/tree';
import { NavProvider, Title } from '@/components/layout/nav';
import { ThemeToggle } from '@/components/layout/theme-toggle';
@@ -94,7 +94,7 @@ export function DocsLayout({
</Navbar>,
nav,
)}
<main
<LayoutBody
id="nd-docs-layout"
{...props.containerProps}
className={cn(
@@ -110,7 +110,10 @@ export function DocsLayout({
) : null}
{replaceOrDefault(
{ enabled: sidebarEnabled, component: sidebarReplace },
<Aside {...sidebar}>
<Aside
{...sidebar}
className="md:flex-1 md:data-[collapsed=true]:flex-initial"
>
<SidebarHeader>
<SidebarHeaderItems {...nav} links={links} />
{sidebarBanner}
@@ -149,7 +152,7 @@ export function DocsLayout({
},
)}
{props.children}
</main>
</LayoutBody>
</NavProvider>
</TreeContextProvider>
);
+2 -2
View File
@@ -15,8 +15,8 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
<header
{...props}
className={cn(
'sticky top-[var(--fd-banner-height)] z-30 flex flex-row items-center border-b border-fd-foreground/10 px-4 transition-colors',
(!isTransparent || open) && 'bg-fd-background/80 backdrop-blur-lg',
'sticky top-[var(--fd-banner-height)] z-30 flex flex-row items-center border-b border-fd-foreground/10 px-4 backdrop-blur-lg transition-colors',
(!isTransparent || open) && 'bg-fd-background/80',
props.className,
)}
>
+3 -3
View File
@@ -119,9 +119,9 @@ export function CollapsibleSidebar(props: SidebarProps) {
onPointerLeave={onLeave}
data-collapsed={collapsed}
className={cn(
'transition-[flex,margin,opacity,transform]',
'md:transition-all',
collapsed &&
'md:-me-[var(--fd-sidebar-width)] md:flex-initial md:translate-x-[calc(var(--fd-sidebar-offset)*-1)] rtl:md:translate-x-[var(--fd-sidebar-offset)]',
'md:-me-[var(--fd-sidebar-width)] md:translate-x-[calc(var(--fd-sidebar-offset)*-1)] rtl:md:translate-x-[var(--fd-sidebar-offset)]',
collapsed && hover && 'z-50 md:translate-x-0',
collapsed && !hover && 'md:opacity-0',
props.className,
@@ -155,7 +155,7 @@ export function Sidebar({
blockScrollingWidth={768} // md
{...props}
className={cn(
'fixed top-fd-layout-top z-30 bg-fd-card text-sm md:sticky md:h-[var(--fd-sidebar-height)] md:flex-1',
'fixed top-fd-layout-top z-30 bg-fd-card text-sm md:sticky md:h-[var(--fd-sidebar-height)]',
'max-md:inset-x-0 max-md:bottom-0 max-md:bg-fd-background/80 max-md:text-[15px] max-md:backdrop-blur-lg max-md:data-[open=false]:invisible',
props.className,
)}
+58 -6
View File
@@ -1,11 +1,13 @@
'use client';
import { cn } from '@/utils/cn';
import type { HTMLAttributes } from 'react';
import type { ButtonHTMLAttributes, HTMLAttributes } from 'react';
import { useSidebar } from '@/contexts/sidebar';
import { useNav } from '@/components/layout/nav';
import { SidebarTrigger } from 'fumadocs-core/sidebar';
import { buttonVariants } from '@/components/ui/button';
import { Menu, X } from 'lucide-react';
export function LayoutBody(props: HTMLAttributes<HTMLElement>) {
const { collapsed } = useSidebar();
return (
<main
id="nd-docs-layout"
@@ -14,9 +16,9 @@ export function LayoutBody(props: HTMLAttributes<HTMLElement>) {
style={
{
...props.style,
'--fd-content-width': collapsed
? '100vw'
: 'calc(min(100vw, var(--fd-layout-width)) - var(--fd-sidebar-width))',
'--fd-layout-offset':
'max(calc(50vw - var(--fd-layout-width) / 2), 0px)',
paddingInlineEnd: 'var(--fd-layout-offset)',
} as object
}
>
@@ -24,3 +26,53 @@ export function LayoutBody(props: HTMLAttributes<HTMLElement>) {
</main>
);
}
export function SubNavbar(props: HTMLAttributes<HTMLElement>) {
const { open, collapsed } = useSidebar();
const { isTransparent } = useNav();
return (
<header
id="nd-subnav"
{...props}
className={cn(
'fixed inset-x-0 top-[var(--fd-banner-height)] z-10 h-14 backdrop-blur-lg transition-colors',
(!isTransparent || open) && 'bg-fd-background/80',
props.className,
)}
style={
{
paddingInlineStart: collapsed
? 'calc(var(--fd-layout-offset))'
: 'calc(var(--fd-layout-offset) + var(--fd-sidebar-width))',
paddingInlineEnd: 'var(--fd-layout-offset)',
} as object
}
>
<div className="mx-auto flex size-full flex-row items-center border-b border-fd-foreground/10 px-4 md:gap-1.5 lg:px-8">
{props.children}
</div>
</header>
);
}
export function NavbarSidebarTrigger(
props: ButtonHTMLAttributes<HTMLButtonElement>,
) {
const { open } = useSidebar();
return (
<SidebarTrigger
{...props}
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon',
}),
props.className,
)}
>
{open ? <X /> : <Menu />}
</SidebarTrigger>
);
}
+14 -20
View File
@@ -17,7 +17,6 @@ import { notFound } from 'next/navigation';
import { RootToggle } from '@/components/layout/root-toggle';
import { TreeContextProvider } from '@/contexts/tree';
import { NavProvider, Title } from '@/components/layout/nav';
import { Navbar, NavbarSidebarTrigger } from '@/layouts/docs/navbar';
import { SearchOnly } from '@/contexts/search';
import {
LargeSearchToggle,
@@ -41,7 +40,7 @@ import {
type SidebarOptions,
} from '@/layouts/docs/shared';
import type { PageTree } from 'fumadocs-core/server';
import { LayoutBody } from './notebook.client';
import { LayoutBody, SubNavbar, NavbarSidebarTrigger } from './notebook.client';
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
@@ -76,13 +75,16 @@ export function DocsLayout({
<LayoutBody
{...props.containerProps}
className={cn(
'[--fd-nav-height:3.5rem] md:[--fd-sidebar-width:260px] lg:[--fd-toc-width:260px] [&_#nd-toc]:max-lg:hidden [&_#nd-tocnav]:lg:hidden',
'[--fd-nav-height:3.5rem] md:[--fd-sidebar-width:260px] lg:[--fd-toc-width:260px] [&_#nd-page]:mt-[var(--fd-nav-height)] [&_#nd-toc]:max-lg:hidden [&_#nd-tocnav]:lg:hidden',
props.containerProps?.className,
)}
>
<Aside
{...sidebar}
className={cn('md:[--fd-nav-height:0px]', sidebar.className)}
className={cn(
'md:ps-[var(--fd-layout-offset)] md:[--fd-nav-height:0px]',
sidebar.className,
)}
>
<SidebarHeader>
<SidebarHeaderItems nav={nav} links={links}>
@@ -108,21 +110,13 @@ export function DocsLayout({
</SidebarViewport>
<SidebarFooter>{sidebarFooter}</SidebarFooter>
</Aside>
<div className="w-full min-w-0 max-w-[var(--fd-content-width)] [--fd-nav-height:3.5rem]">
<DocsNavbar
nav={nav}
links={links}
i18n={i18n}
sidebarCollapsible={sidebarCollapsible}
/>
<div className="flex flex-row">{props.children}</div>
</div>
<div
className="min-w-[var(--fd-sidebar-width)] flex-1"
style={{
marginInlineStart: 'calc(var(--fd-sidebar-width) * -1)',
}}
<DocsNavbar
nav={nav}
links={links}
i18n={i18n}
sidebarCollapsible={sidebarCollapsible}
/>
{props.children}
</LayoutBody>
</NavProvider>
</TreeContextProvider>
@@ -141,7 +135,7 @@ function DocsNavbar({
links: LinkItemType[];
}) {
return (
<Navbar id="nd-subnav" className="h-14 md:gap-1.5">
<SubNavbar>
{sidebarCollapsible ? (
<SidebarCollapseTrigger className="-ms-1.5 text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden" />
) : null}
@@ -188,7 +182,7 @@ function DocsNavbar({
</LanguageToggle>
) : null}
<ThemeToggle className="p-0 max-md:hidden" />
</Navbar>
</SubNavbar>
);
}
+2 -26
View File
@@ -22,30 +22,6 @@ import {
getBreadcrumbItemsFromPath,
} from 'fumadocs-core/breadcrumb';
export function PageContainer(props: HTMLAttributes<HTMLDivElement>) {
const { collapsed } = useSidebar();
return (
<div
{...props}
className={cn(
'flex w-full min-w-0 max-w-[var(--fd-page-width)] flex-col md:transition-[max-width]',
props.className,
)}
style={
{
...props.style,
'--fd-page-width': collapsed
? '100vw'
: 'calc(min(100vw, var(--fd-layout-width)) - var(--fd-sidebar-width) - var(--fd-toc-width))',
} as object
}
>
{props.children}
</div>
);
}
export function PageHeader(props: HTMLAttributes<HTMLDivElement>) {
const { open } = useSidebar();
const { isTransparent } = useNav();
@@ -54,8 +30,8 @@ export function PageHeader(props: HTMLAttributes<HTMLDivElement>) {
<header
{...props}
className={cn(
'sticky top-fd-layout-top z-10 flex flex-row items-center border-b border-fd-foreground/10 text-sm transition-colors',
!isTransparent && 'bg-fd-background/80 backdrop-blur-md',
'sticky top-fd-layout-top z-10 flex flex-row items-center border-b border-fd-foreground/10 text-sm backdrop-blur-md transition-colors',
!isTransparent && 'bg-fd-background/80',
open && 'opacity-0',
props.className,
)}
+26 -12
View File
@@ -14,17 +14,18 @@ import {
Footer,
type FooterProps,
LastUpdate,
PageContainer,
PageHeader,
Breadcrumb,
type BreadcrumbProps,
} from './page.client';
import { Toc, TOCItems, type TOCProps } from '@/components/layout/toc';
import {
Toc,
TOCItems,
TocPopoverTrigger,
TocPopover,
TocPopoverContent,
} from '@/components/layout/toc-popover';
type TOCProps,
} from '@/components/layout/toc';
import { buttonVariants } from '@/components/ui/button';
import { Edit, Text } from 'lucide-react';
import { I18nLabel } from '@/contexts/i18n';
@@ -105,14 +106,14 @@ export interface DocsPageProps {
editOnGithub?: EditOnGitHubOptions;
lastUpdate?: Date | string | number;
container?: HTMLAttributes<HTMLDivElement>;
article?: HTMLAttributes<HTMLElement>;
children: ReactNode;
}
export function DocsPage({
toc = [],
breadcrumb = {},
full = false,
footer = {},
tableOfContentPopover: {
enabled: tocPopoverEnabled = true,
component: tocPopoverReplace,
@@ -135,8 +136,13 @@ export function DocsPage({
return (
<AnchorProvider toc={toc} single={tocOptions.single}>
<PageContainer
<div
id="nd-page"
{...props.container}
className={cn(
'flex w-full min-w-0 max-w-[var(--fd-page-width)] flex-col md:transition-[max-width]',
props.container?.className,
)}
style={
{
'--fd-toc-width': fullWidth ? '0px' : undefined,
@@ -165,14 +171,19 @@ export function DocsPage({
},
)}
<article
{...props.article}
className={cn(
'mx-auto flex w-full flex-1 flex-col gap-6 px-4 pt-10 md:px-7 md:pt-12',
'mx-auto flex w-full flex-1 flex-col gap-6 px-4 pt-8 md:pt-12 lg:px-8',
fullWidth ? 'max-w-[1120px]' : 'max-w-[860px]',
props.article?.className,
)}
>
{replaceOrDefault(
breadcrumb,
<Breadcrumb includePage={breadcrumb.full} {...breadcrumb} />,
props.breadcrumb,
<Breadcrumb
includePage={props.breadcrumb?.full}
{...props.breadcrumb}
/>,
)}
{props.children}
<div role="none" className="flex-1" />
@@ -184,9 +195,12 @@ export function DocsPage({
<LastUpdate date={new Date(props.lastUpdate)} />
) : null}
</div>
{replaceOrDefault(footer, <Footer items={footer.items} />)}
{replaceOrDefault(
props.footer,
<Footer items={props.footer?.items} />,
)}
</article>
</PageContainer>
</div>
{replaceOrDefault(
{ enabled: tocEnabled, component: tocReplace },
<Toc id="nd-toc">
@@ -232,7 +246,7 @@ function EditOnGitHub({
className={cn(
buttonVariants({
color: 'secondary',
className: 'gap-1.5 py-1 text-fd-muted-foreground',
className: 'gap-1.5 text-fd-muted-foreground',
}),
props.className,
)}