feat(radix-ui): rename pointer layout & port to radix-ui

This commit is contained in:
Fuma Nama
2026-07-19 19:33:35 +08:00
parent a283540b65
commit 856eeefbf9
32 changed files with 1636 additions and 300 deletions
+3 -2
View File
@@ -1,9 +1,10 @@
---
packages:
npm:@fumadocs/base-ui: patch
npm:fumadocs-ui: patch
npm:fumadocs-core: patch
---
## Introduce Pointer Layout
## Introduce Glass Layout
A new layout for docs, a smooth, beautiful variant.
A new layout for docs, a smooth, beautiful variant built around floating, translucent panels.
+1 -1
View File
@@ -25,7 +25,7 @@ import {
MarkdownCopyButton,
ViewOptionsPopover,
DocsPageProps,
} from 'fumadocs-ui/layouts/pointer/page';
} from 'fumadocs-ui/layouts/glass/page';
import { NotFound } from '@/components/layouts/not-found';
import { getSuggestions } from './suggestions';
import { PathUtils } from 'fumadocs-core/source';
+1 -1
View File
@@ -1 +1 @@
export { Pointer as default } from '@/components/layouts/pointer';
export { Glass as default } from '@/components/layouts/glass';
+1 -1
View File
@@ -390,7 +390,7 @@ export function AISearchPanel() {
className={cn(
'overflow-hidden z-50 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#fd-pointer-layout]:[grid-area:right] lg:in-[#nd-notebook-layout]:col-start-5',
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#fd-glass-layout]:[grid-area:right] lg:in-[#nd-notebook-layout]:col-start-5',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms] lg:layout:[--fd-right-width:400px]! 2xl:layout:[--fd-right-width:460px]!'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
@@ -1,13 +1,13 @@
'use client';
import { useAISearchContext } from '@/components/inkeep/search';
import { PointerLayout, PointerLayoutProps } from 'fumadocs-ui/layouts/pointer';
import { GlassLayout, GlassLayoutProps } from 'fumadocs-ui/layouts/glass';
export function ClientPointerLayout(props: PointerLayoutProps) {
export function ClientGlassLayout(props: GlassLayoutProps) {
const { open, setOpen } = useAISearchContext();
return (
<PointerLayout
<GlassLayout
{...props}
aiChat={{
open,
@@ -4,10 +4,10 @@ import { AISearch, AISearchPanel } from '@/components/inkeep/search';
import { getSection } from '@/lib/source/navigation';
import type { CSSProperties, ReactNode } from 'react';
import { getLayoutTabs } from 'fumadocs-ui/layouts/shared';
import { ClientPointerLayout } from './client';
import { ClientGlassLayout } from './client';
import 'katex/dist/katex.min.css';
export function Pointer({ children }: { children: ReactNode }) {
export function Glass({ children }: { children: ReactNode }) {
const base = baseOptions();
const tabs = getLayoutTabs(source.getPageTree(), {
transform(option, node) {
@@ -36,7 +36,7 @@ export function Pointer({ children }: { children: ReactNode }) {
return (
<AISearch>
<ClientPointerLayout
<ClientGlassLayout
{...base}
tree={source.getPageTree()}
// just icon items
@@ -54,7 +54,7 @@ export function Pointer({ children }: { children: ReactNode }) {
>
<AISearchPanel />
{children}
</ClientPointerLayout>
</ClientGlassLayout>
</AISearch>
);
}
+113
View File
@@ -0,0 +1,113 @@
---
title: Glass Layout
description: A docs layout with a floating, translucent sidebar and header
---
A docs layout built around floating, translucent (glass-like) panels: the sidebar and header sit on top of the content as rounded, blurred surfaces rather than fixed columns.
<Customization />
## Usage
Enable the Glass layout with `fumadocs-ui/layouts/glass`.
```tsx title="layout.tsx"
import { GlassLayout } from 'fumadocs-ui/layouts/glass'; // [!code highlight]
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<GlassLayout {...baseOptions()} tree={source.getPageTree()}>
{children}
</GlassLayout>
);
}
```
Make sure to update your page import too:
```tsx title="page.tsx"
import { ... } from 'fumadocs-ui/layouts/docs/page'; // [!code --]
import { ... } from 'fumadocs-ui/layouts/glass/page'; // [!code ++]
```
<Callout title="Important to know">
- Glass layout is a **client component**, you cannot pass unserializable props from a server component.
- It is opinionated: the sidebar and header are more fixed than the default layout, use Fumadocs CLI or the `slots` option for customization.
</Callout>
## Configurations
The options are inherited from [Docs Layout](/docs/ui/layouts/docs), with minor differences:
- there is no `sidebar` option, Glass layout is more opinionated than the default one.
- additional options (see below).
### Layout Tabs
Configure [Layout Tabs](/docs/ui/layouts/docs#layout-tabs) with the `tabs` prop, see the linked docs for how to add them. Tabs are shown as a dropdown in the floating header.
```tsx title="layout.tsx"
import { GlassLayout } from 'fumadocs-ui/layouts/glass';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<GlassLayout
{...baseOptions()}
tabs={
{
// customize tabs
}
}
tree={source.getPageTree()}
>
{children}
</GlassLayout>
);
}
```
### AI Chat
The floating header can render an **Ask AI** button. Pass an `aiChat` object with the open state and a change handler to control your AI search dialog.
```tsx title="layout.tsx"
'use client';
import { GlassLayout } from 'fumadocs-ui/layouts/glass';
import { useState } from 'react';
export function Layout({ children }) {
const [open, setOpen] = useState(false);
return (
<GlassLayout
aiChat={{ open, onOpenChange: setOpen }} // [!code ++]
// ...
>
{children}
</GlassLayout>
);
}
```
### Slots
Like other layouts, Glass layout exposes a `slots` option to override the built-in `header`, `sidebar`, and `sidebarDrawer` (mobile) components, in addition to the shared slots (`navTitle`, `themeSwitch`, `searchTrigger`, `languageSelect`).
```tsx title="layout.tsx"
import { GlassLayout } from 'fumadocs-ui/layouts/glass';
<GlassLayout
slots={{
header: MyHeader, // [!code ++]
}}
// ...
/>;
```
+1 -1
View File
@@ -159,7 +159,7 @@
#nd-notebook-layout:has(&),
#nd-home-layout:has(&),
#nd-flux-layout:has(&),
#fd-pointer-layout:has(&) {
#fd-glass-layout:has(&) {
@slot;
}
}
+1 -1
View File
@@ -7,5 +7,5 @@
@import './generated/docs.css';
@import './generated/home.css';
@import './generated/notebook.css';
@import './generated/pointer.css';
@import './generated/glass.css';
@import './generated/shared.css';
+7 -5
View File
@@ -73,6 +73,13 @@
"./layouts/flux/slots/container": "./dist/layouts/flux/slots/container.js",
"./layouts/flux/slots/sidebar": "./dist/layouts/flux/slots/sidebar.js",
"./layouts/flux/slots/tab-dropdown": "./dist/layouts/flux/slots/tab-dropdown.js",
"./layouts/glass": "./dist/layouts/glass/index.js",
"./layouts/glass/page": "./dist/layouts/glass/page/index.js",
"./layouts/glass/page/slots/breadcrumb": "./dist/layouts/glass/page/slots/breadcrumb.js",
"./layouts/glass/page/slots/footer": "./dist/layouts/glass/page/slots/footer.js",
"./layouts/glass/page/slots/toc": "./dist/layouts/glass/page/slots/toc.js",
"./layouts/glass/slots/header": "./dist/layouts/glass/slots/header.js",
"./layouts/glass/slots/sidebar": "./dist/layouts/glass/slots/sidebar.js",
"./layouts/home": "./dist/layouts/home/index.js",
"./layouts/home/navbar": "./dist/layouts/home/navbar.js",
"./layouts/home/not-found": "./dist/layouts/home/not-found.js",
@@ -87,11 +94,6 @@
"./layouts/notebook/slots/container": "./dist/layouts/notebook/slots/container.js",
"./layouts/notebook/slots/header": "./dist/layouts/notebook/slots/header.js",
"./layouts/notebook/slots/sidebar": "./dist/layouts/notebook/slots/sidebar.js",
"./layouts/pointer": "./dist/layouts/pointer/index.js",
"./layouts/pointer/page": "./dist/layouts/pointer/page/index.js",
"./layouts/pointer/page/slots/breadcrumb": "./dist/layouts/pointer/page/slots/breadcrumb.js",
"./layouts/pointer/page/slots/footer": "./dist/layouts/pointer/page/slots/footer.js",
"./layouts/pointer/page/slots/toc": "./dist/layouts/pointer/page/slots/toc.js",
"./layouts/shared": "./dist/layouts/shared/index.js",
"./layouts/shared/slots/language-select": "./dist/layouts/shared/slots/language-select.js",
"./layouts/shared/slots/search-trigger": "./dist/layouts/shared/slots/search-trigger.js",
+32
View File
@@ -137,6 +137,28 @@ export const registry: Registry = {
],
unlisted: true,
},
{
name: 'layouts/glass',
files: [
{
type: 'layout',
path: 'layouts/glass/index.tsx',
target: '<dir>/glass/index.tsx',
meta: {
preserveAs: 'layouts/glass',
},
},
{
type: 'layout',
path: 'layouts/glass/page/index.tsx',
target: '<dir>/glass/page/index.tsx',
meta: {
preserveAs: 'layouts/glass/page',
},
},
],
unlisted: true,
},
{
name: 'layouts/home',
files: [
@@ -302,6 +324,16 @@ export const registry: Registry = {
},
],
},
{
name: 'dropdown-menu',
unlisted: true,
files: [
{
type: 'ui',
path: 'components/ui/dropdown-menu.tsx',
},
],
},
{
name: 'scroll-area',
unlisted: true,
@@ -0,0 +1,115 @@
'use client';
import type * as PageTree from 'fumadocs-core/page-tree';
import {
type BaseLayoutProps,
type BaseSlots,
type BaseSlotsProps,
baseSlots,
getLayoutTabs,
type GetLayoutTabsOptions,
type LayoutTab,
type LinkItemType,
useLinkItems,
} from '@/layouts/shared';
import { TreeContextProvider } from '@/contexts/tree';
import { createContext, type FC, use, useMemo } from 'react';
import { Sidebar, SidebarDrawer, type SidebarProps } from './slots/sidebar';
import { Header, type HeaderProps } from './slots/header';
export interface GlassSlots extends BaseSlots {
header: FC<HeaderProps>;
sidebar: FC<SidebarProps>;
sidebarDrawer: FC<SidebarProps>;
}
export interface GlassLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
aiChat?: {
open: boolean;
onOpenChange: (v: boolean) => void;
};
slots?: Partial<GlassSlots>;
}
interface SlotsProps extends BaseSlotsProps<GlassLayoutProps> {
tabs: LayoutTab[];
aiChat?: GlassLayoutProps['aiChat'];
}
const LayoutContext = createContext<{
props: SlotsProps;
navItems: LinkItemType[];
menuItems: LinkItemType[];
slots: GlassSlots;
} | null>(null);
export function useGlassLayout() {
const context = use(LayoutContext);
if (!context)
throw new Error(
'Please use Glass layout components under <GlassLayout /> (`fumadocs-ui/layouts/glass`).',
);
return context;
}
const { useProvider } = baseSlots({
useProps() {
return useGlassLayout().props;
},
});
export function GlassLayout(props: GlassLayoutProps) {
const { tree, tabs: defaultTabs, aiChat, children, slots: defaultSlots = {} } = props;
const linkItems = useLinkItems(props);
const { baseSlots, baseProps } = useProvider(props);
const tabs = useMemo(() => {
if (Array.isArray(defaultTabs)) {
return defaultTabs;
}
if (typeof defaultTabs === 'object') {
return getLayoutTabs(tree, defaultTabs);
}
if (defaultTabs !== false) {
return getLayoutTabs(tree);
}
return [];
}, [tree, defaultTabs]);
const slots: GlassSlots = {
...baseSlots,
header: defaultSlots.header ?? Header,
sidebar: defaultSlots.sidebar ?? Sidebar,
sidebarDrawer: defaultSlots.sidebarDrawer ?? SidebarDrawer,
};
return (
<LayoutContext
value={{
props: {
tabs,
aiChat,
...baseProps,
},
slots,
...linkItems,
}}
>
<TreeContextProvider tree={tree}>
<div
id="fd-glass-layout"
className="grid overflow-x-clip min-h-dvh [--page-col:900px] [--fd-left-width:0px] [--fd-right-width:0px] has-data-[fd-full=true]:[--page-col:1200px] md:[--fd-left-width:280px]"
style={{
gridTemplate: `"left left-margin main right-margin right" 1fr / var(--fd-left-width) minmax(0,1fr) minmax(0px, var(--page-col)) minmax(0,1fr) var(--fd-right-width)`,
}}
>
<slots.sidebarDrawer />
<slots.sidebar />
<slots.header />
{children}
</div>
</TreeContextProvider>
</LayoutContext>
);
}
@@ -30,7 +30,7 @@ export function useDocsPage() {
const context = use(PageContext);
if (!context)
throw new Error(
'Please use page components under <DocsPage /> (`fumadocs-ui/layouts/docs/page`).',
'Please use page components under <DocsPage /> (`fumadocs-ui/layouts/glass/page`).',
);
return context;
}
@@ -0,0 +1,208 @@
'use client';
import { Drawer } from '@base-ui/react/drawer';
import { isLayoutTabActive, type LayoutTab } from '@/layouts/shared';
import { useTreePath } from '@/contexts/tree';
import Link from 'fumadocs-core/link';
import { type ComponentProps, useState } from 'react';
import {
ChevronDown,
ChevronsUpDown,
LanguagesIcon,
MessageCircleIcon,
MoreHorizontalIcon,
SidebarIcon,
} from 'lucide-react';
import { cn } from '@/utils/cn';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { buttonVariants } from '@/components/ui/button';
import { useTranslations } from '@fuma-translate/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { usePathname } from 'fumadocs-core/framework';
import { useGlassLayout } from '..';
import { drawerHandle } from './sidebar';
export type HeaderProps = ComponentProps<'div'>;
const baseVariants =
'rounded-full bg-fd-popover/80 text-fd-popover-foreground border backdrop-blur-sm shadow-md';
export function Header({ className, ...props }: HeaderProps) {
const {
props: { tabs, aiChat },
slots,
} = useGlassLayout();
const t = useTranslations();
return (
<div
className={cn(
'sticky flex flex-row gap-2 [grid-area:left-margin/left-margin/right/right] z-20 px-4 md:top-0 md:pt-2 md:px-5 md:h-12 md:bg-linear-to-b md:from-fd-background max-md:bottom-0 max-md:mt-auto max-md:h-14 max-md:pb-4 max-md:bg-linear-to-t max-md:from-fd-background',
className,
)}
{...props}
>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
buttonVariants({
variant: 'secondary',
size: 'icon-sm',
}),
baseVariants,
'size-10 shrink-0 data-popup-open:bg-fd-accent md:hidden',
)}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-48">
{aiChat && (
<DropdownMenuItem onClick={() => aiChat.onOpenChange(!aiChat.open)}>
<MessageCircleIcon className="size-4 text-fd-muted-foreground" />
{t('Ask AI', { note: 'AI chat button' })}
</DropdownMenuItem>
)}
{(slots.languageSelect || slots.themeSwitch) && (
<div className="flex items-center justify-between border-t mt-2 pt-2 p-1 gap-2 first:border-t-0 first:mt-0">
{slots.languageSelect && (
<slots.languageSelect.root className="flex-1">
<slots.languageSelect.text />
<ChevronsUpDown className="ms-auto size-3.5 text-fd-muted-foreground shrink-0" />
</slots.languageSelect.root>
)}
{slots.themeSwitch && <slots.themeSwitch className="p-0" />}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{tabs.length > 0 && (
<LayoutTabs tabs={tabs} className={cn(baseVariants, 'min-w-0 max-sm:flex-1')} />
)}
{slots.languageSelect && (
<slots.languageSelect.root className={cn(baseVariants, 'px-3 rounded-full max-md:hidden')}>
<LanguagesIcon className="size-4 text-fd-muted-foreground shrink-0" />
<slots.languageSelect.text />
<ChevronsUpDown className="size-3.5 text-fd-muted-foreground shrink-0" />
</slots.languageSelect.root>
)}
{slots.searchTrigger && (
<>
<slots.searchTrigger.sm
color="secondary"
className={cn(baseVariants, 'ms-auto size-10 shrink-0 lg:hidden')}
/>
<slots.searchTrigger.full
className={cn(
baseVariants,
'ms-auto max-w-[280px] flex-1 text-fd-muted-foreground ps-3 pe-2.5 max-lg:hidden',
)}
/>
</>
)}
{aiChat && !aiChat.open && (
<button
className={cn(
buttonVariants({ variant: 'secondary' }),
baseVariants,
'px-3 gap-2 text-fd-muted-foreground shrink-0 max-md:hidden',
)}
onClick={() => aiChat.onOpenChange(true)}
>
<MessageCircleIcon className="size-4" />
{t('Ask AI', { note: 'AI chat button' })}
</button>
)}
<Drawer.Trigger
handle={drawerHandle}
render={(props, { open }) => (
<button
{...props}
className={cn(
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
baseVariants,
'shrink-0 size-10 md:hidden',
)}
aria-label={
open
? t('Close Sidebar', { note: 'aria-label' })
: t('Open Sidebar', { note: 'aria-label' })
}
>
<SidebarIcon />
</button>
)}
/>
</div>
);
}
function LayoutTabs({
tabs,
className,
...props
}: { tabs: LayoutTab[] } & ComponentProps<typeof PopoverTrigger>) {
const path = useTreePath();
const pathname = usePathname();
const t = useTranslations();
const selected = tabs.findLast((t) => isLayoutTabActive(t, path, pathname));
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
'inline-flex items-center gap-2 text-sm px-3 py-1 font-medium rounded-full transition-colors [&_svg]:size-4 hover:bg-fd-accent data-popup-open:bg-fd-accent',
className,
)}
{...props}
>
{selected ? (
<>
{selected.icon}
<span className="truncate">{selected.title}</span>
</>
) : (
<span className="text-fd-muted-foreground truncate">
{t('Layout Tab', { note: 'layout tab trigger' })}
</span>
)}
<ChevronDown className="ms-auto shrink-0 text-fd-muted-foreground size-3.5!" />
</PopoverTrigger>
<PopoverContent className="flex flex-col p-1 w-(--anchor-width)" align="start">
{tabs.map((t, i) => {
if (t.unlisted && t !== selected) return;
return (
<Link
key={i}
href={t.url}
className={cn(
'text-sm px-2 py-1.5 rounded-lg',
selected === t
? 'bg-fd-primary/10 text-fd-primary'
: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
)}
onClick={() => setOpen(false)}
>
<div className="font-medium inline-flex items-center gap-2 [&_svg]:size-4">
{t.icon}
{t.title}
</div>
<p
className={cn(
'mt-1 text-xs text-fd-muted-foreground empty:hidden',
selected === t && 'text-fd-primary/80',
)}
>
{t.description}
</p>
</Link>
);
})}
</PopoverContent>
</Popover>
);
}
@@ -1,58 +1,24 @@
'use client';
import { Drawer } from '@base-ui/react/drawer';
import type * as PageTree from 'fumadocs-core/page-tree';
import {
getLayoutTabs,
GetLayoutTabsOptions,
GroupedLinkItems,
IconItemType,
isLayoutTabActive,
isLinkItemActive,
LinkItemType,
useLinkItems,
type BaseLayoutProps,
type LayoutTab,
} from '@/layouts/shared';
import { TreeContextProvider, useTreeContext, useTreePath } from '@/contexts/tree';
import { type IconItemType, isLinkItemActive, type LinkItemType } from '@/layouts/shared';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import Link from 'fumadocs-core/link';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cloneElement, ComponentProps, ReactNode, useMemo, useState } from 'react';
import { cloneElement, type ComponentProps, useState } from 'react';
import { cva } from 'class-variance-authority';
import {
ChevronDown,
ChevronsUpDown,
LanguagesIcon,
MessageCircleIcon,
MoreHorizontalIcon,
SidebarIcon,
XIcon,
} from 'lucide-react';
import { ChevronDown, XIcon } from 'lucide-react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { cn } from '@/utils/cn';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { FullSearchTrigger, SearchTrigger } from '../shared/slots/search-trigger';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { buttonVariants } from '@/components/ui/button';
import { ThemeSwitch } from '../shared/slots/theme-switch';
import { LanguageSelect, LanguageSelectText } from '../shared/slots/language-select';
import { useI18n } from '@/contexts/i18n';
import { useTranslations } from '@fuma-translate/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { usePathname } from 'fumadocs-core/framework';
import { useTranslations } from '@fuma-translate/react';
import { useGlassLayout } from '..';
export interface PointerLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
aiChat?: {
open: boolean;
onOpenChange: (v: boolean) => void;
};
}
export type SidebarProps = ComponentProps<'aside'>;
export const drawerHandle = Drawer.createHandle();
const itemTriggerVariants = cva(
'inline-flex text-sm items-center gap-2 rounded-lg px-3 py-2 md:py-1.5 [&_svg]:size-4 outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
@@ -66,59 +32,10 @@ const itemTriggerVariants = cva(
},
);
const drawerHandle = Drawer.createHandle();
export function PointerLayout({
tree,
nav,
tabs: defaultTabs,
links: defaultLinks,
children,
githubUrl,
aiChat,
}: PointerLayoutProps) {
const banner =
typeof nav?.title === 'function' ? (
<nav.title />
) : (
<Link href={nav?.url ?? '/'} className="inline-flex items-center font-semibold gap-2">
{nav?.title}
</Link>
);
const linkItems = useLinkItems({ links: defaultLinks, githubUrl });
const tabs = useMemo(() => {
if (Array.isArray(defaultTabs)) {
return defaultTabs;
}
if (typeof defaultTabs === 'object') {
return getLayoutTabs(tree, defaultTabs);
}
if (defaultTabs !== false) {
return getLayoutTabs(tree);
}
return [];
}, [tree, defaultTabs]);
return (
<TreeContextProvider tree={tree}>
<div
id="fd-pointer-layout"
className="grid overflow-x-clip min-h-dvh [--page-col:900px] [--fd-left-width:0px] [--fd-right-width:0px] has-data-[fd-full=true]:[--page-col:1200px] md:[--fd-left-width:280px]"
style={{
gridTemplate: `"left left-margin main right-margin right" 1fr / var(--fd-left-width) minmax(0,1fr) minmax(0px, var(--page-col)) minmax(0,1fr) var(--fd-right-width)`,
}}
>
<SidebarDrawer banner={banner} links={linkItems} />
<Sidebar banner={banner} links={linkItems} />
<Header tabs={tabs || undefined} aiChat={aiChat} />
{children}
</div>
</TreeContextProvider>
);
}
function SidebarDrawer({ banner, links }: { banner?: ReactNode; links: GroupedLinkItems }) {
export function SidebarDrawer() {
const { menuItems, slots } = useGlassLayout();
const { root } = useTreeContext();
const t = useTranslations();
return (
<Drawer.Root handle={drawerHandle} swipeDirection="right">
@@ -131,9 +48,12 @@ function SidebarDrawer({ banner, links }: { banner?: ReactNode; links: GroupedLi
>
<Drawer.Content className="flex flex-col px-3 size-full overflow-y-auto overscroll-contain fd-scroll-container">
<div className="sticky flex items-center top-0 py-2 bg-fd-background shadow-lg shadow-fd-background">
<Drawer.Title className="px-3 flex-1">{banner}</Drawer.Title>
<Drawer.Title className="px-3 flex-1">
<slots.navTitle className="inline-flex items-center font-semibold gap-2" />
</Drawer.Title>
<Drawer.Close
aria-label={t('Close Sidebar', { note: 'aria-label' })}
className={cn(
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
'rounded-full',
@@ -143,13 +63,13 @@ function SidebarDrawer({ banner, links }: { banner?: ReactNode; links: GroupedLi
</Drawer.Close>
</div>
<div className="flex flex-col py-2 flex-1">
{links.all.map(
{menuItems.map(
(item, i) => item.type !== 'icon' && <SidebarLinkItem key={i} item={item} />,
)}
{root.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
</div>
<div className="flex items-center sticky bottom-0 bg-fd-background px-1.5 pt-2 pb-4 border-t mt-2 empty:hidden">
{links.all.map(
{menuItems.map(
(item, i) => item.type === 'icon' && <SidebarIconLinkItem key={i} item={item} />,
)}
</div>
@@ -161,40 +81,37 @@ function SidebarDrawer({ banner, links }: { banner?: ReactNode; links: GroupedLi
);
}
function Sidebar({
banner,
links,
footer,
}: {
banner?: ReactNode;
links: GroupedLinkItems;
footer?: ReactNode;
}) {
export function Sidebar({ className, children, ...props }: SidebarProps) {
const { menuItems, slots } = useGlassLayout();
const { root } = useTreeContext();
return (
<aside
id="nd-sidebar"
className="sticky flex flex-col [grid-area:left] my-2 ms-2 z-20 top-2 border rounded-2xl bg-fd-popover/80 text-fd-popover-foreground shadow-md h-[calc(100dvh---spacing(4))] max-md:hidden"
className={cn(
'sticky flex flex-col [grid-area:left] my-2 ms-2 z-20 top-2 border rounded-2xl bg-fd-popover/80 text-fd-popover-foreground shadow-md h-[calc(100dvh---spacing(4))] max-md:hidden',
className,
)}
{...props}
>
<div className="flex items-center text-sm gap-2 ps-4 p-2 h-10 border-b border-fd-foreground/10">
{banner}
<ThemeSwitch className="ms-auto shrink-0 p-0 max-md:hidden" />
<slots.navTitle className="inline-flex items-center font-semibold gap-2" />
{slots.themeSwitch && <slots.themeSwitch className="ms-auto shrink-0 p-0 max-md:hidden" />}
</div>
<ScrollArea className="min-h-0 flex-1">
<ScrollViewport className="flex flex-col p-2 [mask-image:linear-gradient(to_bottom,transparent,white_16px,white_calc(100%-16px),transparent))]">
{links.all.map(
{menuItems.map(
(item, i) => item.type !== 'icon' && <SidebarLinkItem key={i} item={item} />,
)}
{root.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
</ScrollViewport>
</ScrollArea>
<div className="flex items-center px-3.5 py-2 border-t mt-2 empty:hidden">
{links.all.map(
{menuItems.map(
(item, i) => item.type === 'icon' && <SidebarIconLinkItem key={i} item={item} />,
)}
</div>
{footer}
{children}
</aside>
);
}
@@ -204,6 +121,7 @@ function SidebarIconLinkItem({ item, className }: { item: IconItemType; classNam
<Link
href={item.url}
external={item.external}
aria-label={item.label}
className={cn(
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
'text-fd-muted-foreground',
@@ -305,169 +223,6 @@ function SidebarLinkItem({
}
}
function Header({ tabs, aiChat }: { tabs?: LayoutTab[] } & Pick<PointerLayoutProps, 'aiChat'>) {
const { locales } = useI18n();
const t = useTranslations();
const baseVariants =
'rounded-full bg-fd-popover/80 text-fd-popover-foreground border backdrop-blur-sm shadow-md';
return (
<div className="sticky flex flex-row gap-2 [grid-area:left-margin/left-margin/right/right] z-20 px-4 md:top-0 md:pt-2 md:px-5 md:h-12 md:bg-linear-to-b md:from-fd-background max-md:bottom-0 max-md:mt-auto max-md:h-14 max-md:pb-4 max-md:bg-linear-to-t max-md:from-fd-background">
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
buttonVariants({
variant: 'secondary',
size: 'icon-sm',
}),
baseVariants,
'size-10 shrink-0 data-popup-open:bg-fd-accent md:hidden',
)}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-48">
{aiChat && (
<DropdownMenuItem onClick={() => aiChat.onOpenChange(!aiChat.open)}>
<MessageCircleIcon className="size-4 text-fd-muted-foreground" />
Ask AI
</DropdownMenuItem>
)}
<div className="flex items-center justify-between border-t mt-2 pt-2 p-1 gap-2 first:border-t-0 first:mt-0">
{locales && locales.length > 0 && (
<LanguageSelect className="flex-1">
<LanguageSelectText />
<ChevronsUpDown className="ms-auto size-3.5 text-fd-muted-foreground shrink-0" />
</LanguageSelect>
)}
<ThemeSwitch className="p-0" />
</div>
</DropdownMenuContent>
</DropdownMenu>
{tabs && tabs.length > 0 && (
<LayoutTabs tabs={tabs} className={cn(baseVariants, 'min-w-0 max-sm:flex-1')} />
)}
{locales && locales.length > 0 && (
<LanguageSelect className={cn(baseVariants, 'px-3 rounded-full max-md:hidden')}>
<LanguagesIcon className="size-4 text-fd-muted-foreground shrink-0" />
<LanguageSelectText />
<ChevronsUpDown className="size-3.5 text-fd-muted-foreground shrink-0" />
</LanguageSelect>
)}
<SearchTrigger
color="secondary"
className={cn(baseVariants, 'ms-auto size-10 shrink-0 lg:hidden')}
/>
<FullSearchTrigger
className={cn(
baseVariants,
'ms-auto max-w-[280px] flex-1 text-fd-muted-foreground ps-3 pe-2.5 max-lg:hidden',
)}
/>
{aiChat && !aiChat.open && (
<button
className={cn(
buttonVariants({ variant: 'secondary' }),
baseVariants,
'px-3 gap-2 text-fd-muted-foreground shrink-0 max-md:hidden',
)}
onClick={() => aiChat.onOpenChange(true)}
>
<MessageCircleIcon className="size-4" />
Ask AI
</button>
)}
<Drawer.Trigger
handle={drawerHandle}
render={(props, { open }) => (
<button
{...props}
className={cn(
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
baseVariants,
'shrink-0 size-10 md:hidden',
)}
aria-label={
open
? t('Close Sidebar', { note: 'aria-label' })
: t('Open Sidebar', { note: 'aria-label' })
}
>
<SidebarIcon />
</button>
)}
/>
</div>
);
}
function LayoutTabs({
tabs,
className,
...props
}: { tabs: LayoutTab[] } & ComponentProps<typeof PopoverTrigger>) {
const path = useTreePath();
const pathname = usePathname();
const t = useTranslations();
const selected = tabs.findLast((t) => isLayoutTabActive(t, path, pathname));
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
'inline-flex items-center gap-2 text-sm px-3 py-1 font-medium rounded-full transition-colors [&_svg]:size-4 hover:bg-fd-accent data-popup-open:bg-fd-accent',
className,
)}
{...props}
>
{selected ? (
<>
{selected.icon}
<span className="truncate">{selected.title}</span>
</>
) : (
<span className="text-fd-muted-foreground truncate">
{t('Layout Tab', { note: 'layout tab trigger' })}
</span>
)}
<ChevronDown className="ms-auto shrink-0 text-fd-muted-foreground size-3.5!" />
</PopoverTrigger>
<PopoverContent className="flex flex-col p-1 w-(--anchor-width)" align="start">
{tabs.map((t, i) => {
if (t.unlisted && t !== selected) return;
return (
<Link
key={i}
href={t.url}
className={cn(
'text-sm px-2 py-1.5 rounded-lg',
selected === t
? 'bg-fd-primary/10 text-fd-primary'
: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
)}
onClick={() => setOpen(false)}
>
<div className="font-medium inline-flex items-center gap-2 [&_svg]:size-4">
{t.icon}
{t.title}
</div>
<p
className={cn(
'mt-1 text-xs text-fd-muted-foreground empty:hidden',
selected === t && 'text-fd-primary/80',
)}
>
{t.description}
</p>
</Link>
);
})}
</PopoverContent>
</Popover>
);
}
function renderNode(node: PageTree.Node) {
if (node.type === 'page') return <SidebarItem item={node} />;
if (node.type === 'folder') return <SidebarFolder folder={node} />;
+1 -1
View File
@@ -72,7 +72,7 @@ export async function compileInline() {
const commonNames = scanner.scan();
await writeFile('css/generated/shared.css', namesToFile(commonNames));
const layouts = ['flux', 'notebook', 'home', 'docs', 'pointer'];
const layouts = ['flux', 'notebook', 'home', 'docs', 'glass'];
const commonNameSet = new Set(commonNames);
for (const layout of layouts) {
-1
View File
@@ -27,7 +27,6 @@ import { registerDefault } from '@/requests/generators/all';
import { createCodeUsageGeneratorRegistry } from '@/requests/generators';
import type { ShikiFactory } from 'fumadocs-core/highlight/shiki';
import type { GeneratedPageProps } from '@/utils/pages/builder';
import type { ParsedSchema } from '@/utils/schema';
import { Markdown } from './components/markdown';
import { Schema, type SchemaUIOptions } from '@fumadocs/api-docs/components/schema';
import { RenderContextProvider } from './contexts/api';
+2 -1
View File
@@ -279,7 +279,8 @@
#nd-docs-layout:has(&),
#nd-notebook-layout:has(&),
#nd-home-layout:has(&),
#nd-flux-layout:has(&) {
#nd-flux-layout:has(&),
#fd-glass-layout:has(&) {
@slot;
}
}
+1
View File
@@ -7,5 +7,6 @@
@import './generated/home.css';
@import './generated/notebook.css';
@import './generated/flux.css';
@import './generated/glass.css';
@import './generated/shared.css';
+7
View File
@@ -76,6 +76,13 @@
"./layouts/flux/slots/container": "./dist/layouts/flux/slots/container.js",
"./layouts/flux/slots/sidebar": "./dist/layouts/flux/slots/sidebar.js",
"./layouts/flux/slots/tab-dropdown": "./dist/layouts/flux/slots/tab-dropdown.js",
"./layouts/glass": "./dist/layouts/glass/index.js",
"./layouts/glass/page": "./dist/layouts/glass/page/index.js",
"./layouts/glass/page/slots/breadcrumb": "./dist/layouts/glass/page/slots/breadcrumb.js",
"./layouts/glass/page/slots/footer": "./dist/layouts/glass/page/slots/footer.js",
"./layouts/glass/page/slots/toc": "./dist/layouts/glass/page/slots/toc.js",
"./layouts/glass/slots/header": "./dist/layouts/glass/slots/header.js",
"./layouts/glass/slots/sidebar": "./dist/layouts/glass/slots/sidebar.js",
"./layouts/home": "./dist/layouts/home/index.js",
"./layouts/home/navbar": "./dist/layouts/home/navbar.js",
"./layouts/home/not-found": "./dist/layouts/home/not-found.js",
+22
View File
@@ -137,6 +137,28 @@ export const registry: Registry = {
],
unlisted: true,
},
{
name: 'layouts/glass',
files: [
{
type: 'layout',
path: 'layouts/glass/index.tsx',
target: '<dir>/glass/index.tsx',
meta: {
preserveAs: 'layouts/glass',
},
},
{
type: 'layout',
path: 'layouts/glass/page/index.tsx',
target: '<dir>/glass/page/index.tsx',
meta: {
preserveAs: 'layouts/glass/page',
},
},
],
unlisted: true,
},
{
name: 'layouts/home',
files: [
@@ -0,0 +1,118 @@
'use client';
import type * as PageTree from 'fumadocs-core/page-tree';
import {
type BaseLayoutProps,
type BaseSlots,
type BaseSlotsProps,
baseSlots,
getLayoutTabs,
type GetLayoutTabsOptions,
type LayoutTab,
type LinkItemType,
useLinkItems,
} from '@/layouts/shared';
import { TreeContextProvider } from '@/contexts/tree';
import { createContext, type FC, use, useMemo } from 'react';
import { SidebarProvider } from '@/components/sidebar/base';
import { Sidebar, SidebarDrawer, type SidebarProps } from './slots/sidebar';
import { Header, type HeaderProps } from './slots/header';
export interface GlassSlots extends BaseSlots {
header: FC<HeaderProps>;
sidebar: FC<SidebarProps>;
sidebarDrawer: FC<SidebarProps>;
}
export interface GlassLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
aiChat?: {
open: boolean;
onOpenChange: (v: boolean) => void;
};
slots?: Partial<GlassSlots>;
}
interface SlotsProps extends BaseSlotsProps<GlassLayoutProps> {
tabs: LayoutTab[];
aiChat?: GlassLayoutProps['aiChat'];
}
const LayoutContext = createContext<{
props: SlotsProps;
navItems: LinkItemType[];
menuItems: LinkItemType[];
slots: GlassSlots;
} | null>(null);
export function useGlassLayout() {
const context = use(LayoutContext);
if (!context)
throw new Error(
'Please use Glass layout components under <GlassLayout /> (`fumadocs-ui/layouts/glass`).',
);
return context;
}
const { useProvider } = baseSlots({
useProps() {
return useGlassLayout().props;
},
});
export function GlassLayout(props: GlassLayoutProps) {
const { tree, tabs: defaultTabs, aiChat, children, slots: defaultSlots = {} } = props;
const linkItems = useLinkItems(props);
const { baseSlots, baseProps } = useProvider(props);
const tabs = useMemo(() => {
if (Array.isArray(defaultTabs)) {
return defaultTabs;
}
if (typeof defaultTabs === 'object') {
return getLayoutTabs(tree, defaultTabs);
}
if (defaultTabs !== false) {
return getLayoutTabs(tree);
}
return [];
}, [tree, defaultTabs]);
const slots: GlassSlots = {
...baseSlots,
header: defaultSlots.header ?? Header,
sidebar: defaultSlots.sidebar ?? Sidebar,
sidebarDrawer: defaultSlots.sidebarDrawer ?? SidebarDrawer,
};
return (
<LayoutContext
value={{
props: {
tabs,
aiChat,
...baseProps,
},
slots,
...linkItems,
}}
>
<TreeContextProvider tree={tree}>
<SidebarProvider>
<div
id="fd-glass-layout"
className="grid overflow-x-clip min-h-dvh [--page-col:900px] [--fd-left-width:0px] [--fd-right-width:0px] has-data-[fd-full=true]:[--page-col:1200px] md:[--fd-left-width:280px]"
style={{
gridTemplate: `"left left-margin main right-margin right" 1fr / var(--fd-left-width) minmax(0,1fr) minmax(0px, var(--page-col)) minmax(0,1fr) var(--fd-right-width)`,
}}
>
<slots.sidebarDrawer />
<slots.sidebar />
<slots.header />
{children}
</div>
</SidebarProvider>
</TreeContextProvider>
</LayoutContext>
);
}
@@ -0,0 +1,138 @@
'use client';
import { type ComponentProps, createContext, use, useEffect, useState } from 'react';
import { cn } from '@/utils/cn';
import { useTranslations } from '@fuma-translate/react';
import { buttonVariants } from '@/components/ui/button';
import { Edit } from 'lucide-react';
import { Footer } from './slots/footer';
import { Breadcrumb } from './slots/breadcrumb';
import { TOC, type TOCProps, TOCProvider } from './slots/toc';
import { TOCItemType } from 'fumadocs-core/toc';
export interface DocsPageProps extends ComponentProps<'article'> {
toc?: TOCItemType[];
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean | undefined;
tableOfContent?: TOCProps;
}
const PageContext = createContext<{
full: boolean;
} | null>(null);
export function useDocsPage() {
const context = use(PageContext);
if (!context)
throw new Error(
'Please use page components under <DocsPage /> (`fumadocs-ui/layouts/glass/page`).',
);
return context;
}
const Empty: never[] = [];
export function DocsPage({ full = false, toc = Empty, tableOfContent, children }: DocsPageProps) {
return (
<PageContext
value={{
full,
}}
>
<TOCProvider toc={toc}>
<div
data-fd-full={full}
className="flex flex-col gap-2 p-6 pb-16 min-w-0 [grid-area:main] md:px-8 md:pt-16 md:pb-8"
>
<Breadcrumb />
{children}
<Footer />
</div>
<TOC {...tableOfContent} />
</TOCProvider>
</PageContext>
);
}
export function EditOnGitHub(props: ComponentProps<'a'>) {
const t = useTranslations({ note: 'edit page' });
return (
<a
target="_blank"
rel="noreferrer noopener"
{...props}
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
}),
'gap-1.5 not-prose',
props.className,
)}
>
{props.children ?? (
<>
<Edit className="size-3.5" />
{t('Edit on GitHub')}
</>
)}
</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>
);
}
export function PageLastUpdate({
date: value,
...props
}: Omit<ComponentProps<'p'>, 'children'> & { date: Date }) {
const t = useTranslations({ note: 'page footer' });
const [date, setDate] = useState('');
useEffect(() => {
// to the timezone of client
setDate(value.toLocaleDateString());
}, [value]);
return (
<p {...props} className={cn('text-sm text-fd-muted-foreground', props.className)}>
{t('Last updated on')} {date}
</p>
);
}
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
@@ -0,0 +1,56 @@
'use client';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { type BreadcrumbOptions, getBreadcrumbItemsFromPath } from 'fumadocs-core/breadcrumb';
import { ChevronRight } from 'lucide-react';
import Link from 'fumadocs-core/link';
import { type ComponentProps, useMemo, Fragment } from 'react';
export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>;
export function Breadcrumb({
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>
);
}
@@ -0,0 +1,84 @@
'use client';
import { useTranslations } from '@fuma-translate/react';
import { cn } from '@/utils/cn';
import { isActive } from '@/utils/urls';
import { useFooterItems } from '@/utils/use-footer-items';
import { usePathname } from 'fumadocs-core/framework';
import Link from 'fumadocs-core/link';
import type * as PageTree from 'fumadocs-core/page-tree';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { type ComponentProps, useMemo } from 'react';
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;
};
}
export function Footer({ items, children, className, ...props }: FooterProps) {
const footerList = useFooterItems();
const pathname = usePathname();
const { previous, next } = useMemo(() => {
if (items) return items;
const idx = footerList.findIndex((item) => isActive(item.url, pathname));
if (idx === -1) return {};
return {
previous: footerList[idx - 1],
next: footerList[idx + 1],
};
}, [footerList, items, pathname]);
return (
<>
<div
className={cn(
'@container grid gap-4',
previous && next ? 'grid-cols-2' : 'grid-cols-1',
className,
)}
{...props}
>
{previous && <FooterItem item={previous} index={0} />}
{next && <FooterItem item={next} index={1} />}
</div>
{children}
</>
);
}
function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) {
const t = useTranslations({ note: 'pagination' });
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 ? t('Previous Page') : t('Next Page'))}
</p>
</Link>
);
}
@@ -0,0 +1,174 @@
'use client';
import * as Base from '@/components/toc';
import { useTranslations } from '@fuma-translate/react';
import { cn } from '@/utils/cn';
import { Text } from 'lucide-react';
import {
createContext,
use,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from 'react';
import { TOCScrollArea, useTOCItems } from '@/components/toc';
import { TOCItem } from 'fumadocs-core/toc';
export type TOCProviderProps = Base.TOCProviderProps;
const Context = createContext<{
open: boolean;
setMobileOpen: (v: boolean) => void;
} | null>(null);
export function TOCProvider(props: TOCProviderProps) {
return <Base.TOCProvider {...props} />;
}
export interface TOCProps {
container?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
}
function useContext() {
return use(Context)!;
}
export function TOC({ container, header, footer }: TOCProps) {
const t = useTranslations({ note: 'table of contents' });
const items = Base.useTOCItems();
const [hover, setHover] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const exitTimerRef = useRef<number>(null);
const open = hover || mobileOpen;
const ctx = useMemo(
() => ({
open,
setMobileOpen,
}),
[open],
);
if (items.length === 0 && !footer && !header) {
return;
}
return (
<Context value={ctx}>
<div
className={cn(
'fixed inset-0 z-10 backdrop-blur-sm transition-opacity duration-300 [mask-image:radial-gradient(circle_at_center_right,white,white_200px,transparent_500px)] xl:hidden',
!open && 'opacity-0 pointer-events-none',
)}
onClick={() => setMobileOpen(false)}
/>
<TOCScrollArea
id="nd-toc"
{...container}
className={cn(
'z-10 flex flex-col transition-[width,padding] duration-300 [grid-area:right] layout:[--fd-right-width:0px] max-xl:fixed max-xl:top-1/2 max-xl:-translate-y-1/2 max-xl:end-1 max-xl:bg-fd-popover max-xl:text-fd-popover-foreground max-xl:border max-xl:rounded-xl max-xl:shadow-lg max-xl:mask-none xl:sticky xl:top-10 xl:h-[calc(100dvh---spacing(10))] xl:layout:[--fd-right-width:240px]',
open
? 'max-xl:w-[240px] max-xl:p-3'
: 'max-md:w-4 max-md:ps-[calc(--spacing(1.5)-1px)] max-xl:w-6 max-xl:ps-[calc(--spacing(2.5)-1px)]',
container?.className,
)}
onPointerDown={(e) => {
if (!mobileOpen && (e.pointerType === 'touch' || e.pointerType === 'pen')) {
setMobileOpen(true);
e.stopPropagation();
}
}}
onPointerEnter={(e) => {
if (e.pointerType === 'mouse') {
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
setHover(true);
}
}}
onPointerLeave={(e) => {
if (e.pointerType === 'mouse') {
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = window.setTimeout(
() => {
setHover(false);
},
// If the pointer leaves the window, use a longer timeout
window.innerWidth - e.clientX < 30 ? 700 : 50,
);
}
}}
>
<div className="flex flex-col my-auto max-xl:min-w-[calc(240px---spacing(6))] xl:items-end xl:text-end xl:pe-4">
{header}
<h3
id="nd-toc-title"
className={cn(
'inline-flex items-center gap-1.5 overflow-hidden text-xs text-fd-muted-foreground transition-[opacity,height] opacity-0 h-0',
open
? 'opacity-100 h-6'
: 'xl:[@media(hover:none)]:opacity-100 xl:[@media(hover:none)]:h-6',
)}
>
<Text className="size-3.5 shrink-0" />
{t('On this page')}
</h3>
<TOCPanel />
{footer}
</div>
</TOCScrollArea>
</Context>
);
}
function TOCPanel({ className, ...props }: ComponentProps<'div'>) {
const items = useTOCItems();
const { open, setMobileOpen } = useContext();
return (
<div className={cn('flex flex-col', className)} {...props}>
{items.map((item) => (
<TOCItem
key={item.url}
href={item.url}
className={cn(
'group h-6 prose prose-sm inline-flex items-center gap-2 text-xs text-fd-muted-foreground transition-[color,height] data-[active=true]:text-fd-primary data-[active=false]:hover:text-fd-accent-foreground xl:flex-row-reverse',
!open && 'h-3 xl:[@media(hover:none)]:h-6',
)}
onClick={() => setMobileOpen(false)}
autoScroll={open}
>
<div
className={cn(
'shrink-0 bg-fd-muted-foreground/50 rounded-full size-1 transition-[background-color,width,height] group-data-[active=true]:bg-fd-primary',
open
? 'w-(--width) h-px'
: 'xl:[@media(hover:none)]:w-(--width) xl:[@media(hover:none)]:h-px',
)}
style={
{
'--width': `calc(pow(${item.depth}, 1.5) * var(--spacing))`,
} as object
}
/>
<span
className={cn(
'truncate transition-opacity opacity-0',
open ? 'opacity-100' : 'xl:[@media(hover:none)]:opacity-100',
)}
>
{item.title}
</span>
</TOCItem>
))}
</div>
);
}
@@ -0,0 +1,192 @@
'use client';
import { isLayoutTabActive, type LayoutTab } from '@/layouts/shared';
import Link from 'fumadocs-core/link';
import { type ComponentProps, useState } from 'react';
import {
ChevronDown,
ChevronsUpDown,
LanguagesIcon,
MessageCircleIcon,
MoreHorizontalIcon,
SidebarIcon,
} from 'lucide-react';
import { cn } from '@/utils/cn';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { buttonVariants } from '@/components/ui/button';
import { useTranslations } from '@fuma-translate/react';
import { usePathname } from 'fumadocs-core/framework';
import { SidebarTrigger } from '@/components/sidebar/base';
import { useGlassLayout } from '..';
export type HeaderProps = ComponentProps<'div'>;
const baseVariants =
'rounded-full bg-fd-popover/80 text-fd-popover-foreground border backdrop-blur-sm shadow-md';
export function Header({ className, ...props }: HeaderProps) {
const {
props: { tabs, aiChat },
slots,
} = useGlassLayout();
const t = useTranslations();
return (
<div
className={cn(
'sticky flex flex-row gap-2 [grid-area:left-margin/left-margin/right/right] z-20 px-4 md:top-0 md:pt-2 md:px-5 md:h-12 md:bg-linear-to-b md:from-fd-background max-md:bottom-0 max-md:mt-auto max-md:h-14 max-md:pb-4 max-md:bg-linear-to-t max-md:from-fd-background',
className,
)}
{...props}
>
<Popover>
<PopoverTrigger
className={cn(
buttonVariants({
variant: 'secondary',
size: 'icon-sm',
}),
baseVariants,
'size-10 shrink-0 data-[state=open]:bg-fd-accent md:hidden',
)}
>
<MoreHorizontalIcon />
</PopoverTrigger>
<PopoverContent align="start" className="flex flex-col min-w-48 p-1">
{aiChat && (
<button
type="button"
onClick={() => aiChat.onOpenChange(!aiChat.open)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium hover:bg-fd-accent hover:text-fd-accent-foreground"
>
<MessageCircleIcon className="size-4 text-fd-muted-foreground" />
{t('Ask AI', { note: 'AI chat button' })}
</button>
)}
{(slots.languageSelect || slots.themeSwitch) && (
<div className="flex items-center justify-between border-t mt-2 pt-2 p-1 gap-2 first:border-t-0 first:mt-0">
{slots.languageSelect && (
<slots.languageSelect.root className="flex-1">
<slots.languageSelect.text />
<ChevronsUpDown className="ms-auto size-3.5 text-fd-muted-foreground shrink-0" />
</slots.languageSelect.root>
)}
{slots.themeSwitch && <slots.themeSwitch className="p-0" />}
</div>
)}
</PopoverContent>
</Popover>
{tabs.length > 0 && (
<LayoutTabs tabs={tabs} className={cn(baseVariants, 'min-w-0 max-sm:flex-1')} />
)}
{slots.languageSelect && (
<slots.languageSelect.root className={cn(baseVariants, 'px-3 rounded-full max-md:hidden')}>
<LanguagesIcon className="size-4 text-fd-muted-foreground shrink-0" />
<slots.languageSelect.text />
<ChevronsUpDown className="size-3.5 text-fd-muted-foreground shrink-0" />
</slots.languageSelect.root>
)}
{slots.searchTrigger && (
<>
<slots.searchTrigger.sm
color="secondary"
className={cn(baseVariants, 'ms-auto size-10 shrink-0 lg:hidden')}
/>
<slots.searchTrigger.full
className={cn(
baseVariants,
'ms-auto max-w-[280px] flex-1 text-fd-muted-foreground ps-3 pe-2.5 max-lg:hidden',
)}
/>
</>
)}
{aiChat && !aiChat.open && (
<button
className={cn(
buttonVariants({ variant: 'secondary' }),
baseVariants,
'px-3 gap-2 text-fd-muted-foreground shrink-0 max-md:hidden',
)}
onClick={() => aiChat.onOpenChange(true)}
>
<MessageCircleIcon className="size-4" />
{t('Ask AI', { note: 'AI chat button' })}
</button>
)}
<SidebarTrigger
className={cn(
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
baseVariants,
'shrink-0 size-10 md:hidden',
)}
>
<SidebarIcon />
</SidebarTrigger>
</div>
);
}
function LayoutTabs({
tabs,
className,
...props
}: { tabs: LayoutTab[] } & ComponentProps<typeof PopoverTrigger>) {
const pathname = usePathname();
const t = useTranslations();
const selected = tabs.findLast((t) => isLayoutTabActive(t, pathname));
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
'inline-flex items-center gap-2 text-sm px-3 py-1 font-medium rounded-full transition-colors [&_svg]:size-4 hover:bg-fd-accent data-[state=open]:bg-fd-accent',
className,
)}
{...props}
>
{selected ? (
<>
{selected.icon}
<span className="truncate">{selected.title}</span>
</>
) : (
<span className="text-fd-muted-foreground truncate">
{t('Layout Tab', { note: 'layout tab trigger' })}
</span>
)}
<ChevronDown className="ms-auto shrink-0 text-fd-muted-foreground size-3.5!" />
</PopoverTrigger>
<PopoverContent className="flex flex-col p-1 w-(--radix-popover-trigger-width)" align="start">
{tabs.map((t, i) => {
if (t.unlisted && t !== selected) return;
return (
<Link
key={i}
href={t.url}
className={cn(
'text-sm px-2 py-1.5 rounded-lg',
selected === t
? 'bg-fd-primary/10 text-fd-primary'
: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
)}
onClick={() => setOpen(false)}
>
<div className="font-medium inline-flex items-center gap-2 [&_svg]:size-4">
{t.icon}
{t.title}
</div>
<p
className={cn(
'mt-1 text-xs text-fd-muted-foreground empty:hidden',
selected === t && 'text-fd-primary/80',
)}
>
{t.description}
</p>
</Link>
);
})}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,318 @@
'use client';
import type * as PageTree from 'fumadocs-core/page-tree';
import { type IconItemType, isLinkItemActive, type LinkItemType } from '@/layouts/shared';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import Link from 'fumadocs-core/link';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cloneElement, type ComponentProps, useState } from 'react';
import { cva } from 'class-variance-authority';
import { ChevronDown, XIcon } from 'lucide-react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { cn } from '@/utils/cn';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { buttonVariants } from '@/components/ui/button';
import { usePathname } from 'fumadocs-core/framework';
import { useTranslations } from '@fuma-translate/react';
import { SidebarDrawerContent, SidebarDrawerOverlay, useSidebar } from '@/components/sidebar/base';
import { useGlassLayout } from '..';
export type SidebarProps = ComponentProps<'aside'>;
const itemTriggerVariants = cva(
'inline-flex text-sm items-center gap-2 rounded-lg px-3 py-2 md:py-1.5 [&_svg]:size-4 outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
{
variants: {
active: {
true: 'bg-fd-primary/10 text-fd-primary',
false: 'text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
},
},
},
);
export function SidebarDrawer() {
const { menuItems, slots } = useGlassLayout();
const { root } = useTreeContext();
const { setOpen } = useSidebar();
const t = useTranslations();
return (
<>
<SidebarDrawerOverlay className="fixed z-40 inset-0 bg-fd-overlay backdrop-blur-sm data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out" />
<SidebarDrawerContent className="fixed z-40 inset-e-0 inset-y-0 flex flex-col w-[360px] max-w-[calc(100vw-3rem)] border-s bg-fd-background text-fd-foreground shadow-md data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out">
<div className="flex flex-col px-3 size-full overflow-y-auto overscroll-contain fd-scroll-container">
<div className="sticky flex items-center top-0 py-2 bg-fd-background shadow-lg shadow-fd-background">
<div className="px-3 flex-1">
<slots.navTitle className="inline-flex items-center font-semibold gap-2" />
</div>
<button
type="button"
aria-label={t('Close Sidebar', { note: 'aria-label' })}
onClick={() => setOpen(false)}
className={cn(
buttonVariants({ variant: 'secondary', size: 'icon-sm' }),
'rounded-full',
)}
>
<XIcon />
</button>
</div>
<div className="flex flex-col py-2 flex-1">
{menuItems.map(
(item, i) => item.type !== 'icon' && <SidebarLinkItem key={i} item={item} />,
)}
{root.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
</div>
<div className="flex items-center sticky bottom-0 bg-fd-background px-1.5 pt-2 pb-4 border-t mt-2 empty:hidden">
{menuItems.map(
(item, i) => item.type === 'icon' && <SidebarIconLinkItem key={i} item={item} />,
)}
</div>
</div>
</SidebarDrawerContent>
</>
);
}
export function Sidebar({ className, children, ...props }: SidebarProps) {
const { menuItems, slots } = useGlassLayout();
const { root } = useTreeContext();
return (
<aside
id="nd-sidebar"
className={cn(
'sticky flex flex-col [grid-area:left] my-2 ms-2 z-20 top-2 border rounded-2xl bg-fd-popover/80 text-fd-popover-foreground shadow-md h-[calc(100dvh---spacing(4))] max-md:hidden',
className,
)}
{...props}
>
<div className="flex items-center text-sm gap-2 ps-4 p-2 h-10 border-b border-fd-foreground/10">
<slots.navTitle className="inline-flex items-center font-semibold gap-2" />
{slots.themeSwitch && <slots.themeSwitch className="ms-auto shrink-0 p-0 max-md:hidden" />}
</div>
<ScrollArea className="min-h-0 flex-1">
<ScrollViewport className="flex flex-col p-2 [mask-image:linear-gradient(to_bottom,transparent,white_16px,white_calc(100%-16px),transparent))]">
{menuItems.map(
(item, i) => item.type !== 'icon' && <SidebarLinkItem key={i} item={item} />,
)}
{root.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
</ScrollViewport>
</ScrollArea>
<div className="flex items-center px-3.5 py-2 border-t mt-2 empty:hidden">
{menuItems.map(
(item, i) => item.type === 'icon' && <SidebarIconLinkItem key={i} item={item} />,
)}
</div>
{children}
</aside>
);
}
function SidebarIconLinkItem({ item, className }: { item: IconItemType; className?: string }) {
return (
<Link
href={item.url}
external={item.external}
aria-label={item.label}
className={cn(
buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
'text-fd-muted-foreground',
className,
)}
>
{item.icon}
</Link>
);
}
function SidebarLinkItem({
item,
className,
}: {
item: Exclude<LinkItemType, { type: 'icon' }>;
className?: string;
}) {
const pathname = usePathname();
const [open, setOpen] = useState(true);
switch (item.type) {
case 'custom':
return <div className={className}>{item.children}</div>;
case 'menu': {
const rightIcon = (
<ChevronDown
className={cn(
'ms-auto text-fd-muted-foreground size-3.5! transition-transform',
!open && '-rotate-90',
)}
/>
);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className={cn('mt-4 first:mt-0', className)}
>
{item.url ? (
<div className="flex w-full text-sm px-3 py-1.5 font-medium">
<Link
href={item.url}
external={item.external}
className={cn(
'inline-flex items-center gap-2 [&_svg]:size-4',
isLinkItemActive(item, pathname)
? 'text-fd-primary'
: 'hover:underline hover:decoration-fd-muted-foreground hover:underline-offset-4 hover:decoration-dashed hover:text-fd-accent-foreground',
)}
>
{item.icon}
{item.text}
</Link>
<CollapsibleTrigger className="flex-1">{rightIcon}</CollapsibleTrigger>
</div>
) : (
<CollapsibleTrigger className="w-full text-sm px-3 py-1.5 font-medium inline-flex items-center gap-2 [&_svg]:size-4">
{item.icon}
{item.text}
{rightIcon}
</CollapsibleTrigger>
)}
<CollapsibleContent className="flex flex-col">
{item.items.map((item, i) => (
<SidebarLinkItem key={i} item={item} />
))}
</CollapsibleContent>
</Collapsible>
);
}
default:
return (
<Link
href={item.url}
external={item.external}
className={cn(
itemTriggerVariants({
active: isLinkItemActive(item, pathname),
className,
}),
)}
>
{item.icon}
{item.text}
</Link>
);
}
}
function renderNode(node: PageTree.Node) {
if (node.type === 'page') return <SidebarItem item={node} />;
if (node.type === 'folder') return <SidebarFolder folder={node} />;
return (
<p className="mt-4 w-full text-sm px-3 py-1.5 font-medium inline-flex items-center gap-2 [&_svg]:size-4 empty:hidden first:mt-0">
{node.icon}
{node.name}
</p>
);
}
function SidebarItem({ item }: { item: PageTree.Item }) {
const path = useTreePath();
return (
<Link
href={item.url}
external={item.external}
className={itemTriggerVariants({
active: isNodeInPath(item, path),
})}
>
{item.icon}
{item.name}
</Link>
);
}
function SidebarFolder({ folder }: { folder: PageTree.Folder }) {
const path = useTreePath();
const shouldOpen = (folder.defaultOpen ?? true) || isNodeInPath(folder, path);
const [open, setOpen] = useState(shouldOpen);
useOnChange(shouldOpen, () => shouldOpen && setOpen(true));
if (folder.collapsible === false) {
return (
<>
{folder.index ? (
<Link
href={folder.index.url}
external={folder.index.external}
className={cn(
'inline-flex text-sm px-3 py-1.5 font-medium items-center gap-2 [&_svg]:size-4 mt-4 first:mt-0',
isNodeInPath(folder.index, path)
? 'text-fd-primary'
: 'hover:underline hover:decoration-fd-muted-foreground hover:underline-offset-4 hover:decoration-dashed hover:text-fd-accent-foreground',
)}
>
{folder.icon}
{folder.name}
</Link>
) : (
<div className="w-full text-sm px-3 py-1.5 font-medium inline-flex items-center gap-2 [&_svg]:size-4 mt-4 first:mt-0">
{folder.icon}
{folder.name}
</div>
)}
{folder.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
</>
);
}
const rightIcon = (
<ChevronDown
className={cn(
'ms-auto text-fd-muted-foreground size-3.5! transition-transform',
!open && '-rotate-90',
)}
/>
);
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-4 first:mt-0">
{folder.index ? (
<div className="flex w-full text-sm px-3 py-1.5 font-medium">
<Link
href={folder.index.url}
external={folder.index.external}
className={cn(
'inline-flex items-center gap-2 [&_svg]:size-4',
isNodeInPath(folder.index, path)
? 'text-fd-primary'
: 'hover:underline hover:decoration-fd-muted-foreground hover:underline-offset-4 hover:decoration-dashed hover:text-fd-accent-foreground',
)}
>
{folder.icon}
{folder.name}
</Link>
<CollapsibleTrigger className="flex-1">{rightIcon}</CollapsibleTrigger>
</div>
) : (
<CollapsibleTrigger className="w-full text-sm px-3 py-1.5 font-medium inline-flex items-center gap-2 [&_svg]:size-4">
{folder.icon}
{folder.name}
{rightIcon}
</CollapsibleTrigger>
)}
<CollapsibleContent className="flex flex-col">
{folder.children.map((item, i) => cloneElement(renderNode(item), { key: i }))}
</CollapsibleContent>
</Collapsible>
);
}
function isNodeInPath(node: PageTree.Node, path: PageTree.Node[]) {
return path.some((other) => other.$id === node.$id || other === node);
}
+1 -1
View File
@@ -75,7 +75,7 @@ async function compileInline() {
const commonNames = scanner.scan();
await writeFile('css/generated/shared.css', namesToFile(commonNames));
const layouts = ['flux', 'notebook', 'home', 'docs'];
const layouts = ['flux', 'notebook', 'home', 'docs', 'glass'];
const commonNameSet = new Set(commonNames);
for (const layout of layouts) {