UI: include Flux layout to Base UI

This commit is contained in:
Fuma Nama
2026-02-06 17:35:06 +08:00
parent db93ebdf6d
commit c08364ab81
12 changed files with 1258 additions and 7 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@fumadocs/base-ui': patch
'fumadocs-ui': patch
---
support Flux layout
+1 -1
View File
@@ -1,5 +1,5 @@
---
"@fumadocs/tailwind": patch
'@fumadocs/tailwind': patch
---
initial release
+1
View File
@@ -0,0 +1 @@
@source '../../dist/layouts/flux/**/*.js';
+1
View File
@@ -1,6 +1,7 @@
@import './lib/base.css';
/* TODO: remove them on next major */
@import './layouts/flux.css';
@import './layouts/docs.css';
@import './layouts/home.css';
@import './layouts/notebook.css';
+10
View File
@@ -44,6 +44,14 @@
"types": "./dist/layouts/docs/page/index.d.ts",
"import": "./dist/layouts/docs/page/index.js"
},
"./layouts/flux": {
"types": "./dist/layouts/flux/index.d.ts",
"import": "./dist/layouts/flux/index.js"
},
"./layouts/flux/page": {
"types": "./dist/layouts/flux/page/index.d.ts",
"import": "./dist/layouts/flux/page/index.js"
},
"./layouts/notebook": {
"types": "./dist/layouts/notebook/index.d.ts",
"import": "./dist/layouts/notebook/index.js"
@@ -99,8 +107,10 @@
"@fumadocs/tailwind": "workspace:*",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.563.0",
"motion": "^12.30.1",
"next-themes": "^0.4.6",
"react-medium-image-zoom": "^5.4.0",
"react-remove-scroll": "^2.7.2",
"scroll-into-view-if-needed": "^3.1.0",
"tailwind-merge": "^3.4.0"
},
@@ -54,7 +54,7 @@ export function SidebarTabsDropdown({
<PopoverTrigger
{...props}
className={cn(
'flex items-center gap-2 rounded-lg p-2 border bg-fd-secondary/50 text-start text-fd-secondary-foreground transition-colors hover:bg-fd-accent data-[open]:bg-fd-accent data-[open]:text-fd-accent-foreground',
'flex items-center gap-2 rounded-lg p-2 border bg-fd-secondary/50 text-start text-fd-secondary-foreground transition-colors hover:bg-fd-accent data-open:bg-fd-accent data-open:text-fd-accent-foreground',
props.className,
)}
>
+285
View File
@@ -0,0 +1,285 @@
'use client';
import type * as PageTree from 'fumadocs-core/page-tree';
import type { BaseLayoutProps } from '@/layouts/shared';
import { TreeContextProvider } from '@/contexts/tree';
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
import { type ComponentProps, HTMLAttributes, type ReactNode, useMemo } from 'react';
import { cn } from '@/utils/cn';
import { SidebarViewport, useSidebar } from '@/components/sidebar/base';
import { SidebarTabsDropdown, type SidebarTabWithProps } from './tab-dropdown';
import { Sidebar, SidebarContent, SidebarLinkItem, SidebarPageTree } from './sidebar';
import { buttonVariants } from '@/components/ui/button';
import { Languages, SidebarIcon, XIcon } from 'lucide-react';
import { renderTitleNav, useLinkItems } from '../shared';
import { LanguageToggle } from '../shared/language-toggle';
import { SearchToggle } from '../shared/search-toggle';
import { ThemeToggle } from '../shared/theme-toggle';
import { LinkItem } from '@/utils/link-item';
import { AnimatePresence, motion } from 'motion/react';
import { RemoveScroll } from 'react-remove-scroll';
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
sidebar?: SidebarOptions;
/**
* Props for the `div` container
*/
containerProps?: HTMLAttributes<HTMLDivElement>;
renderNavigationPanel?: (props: NavigationPanelProps) => ReactNode;
}
interface SidebarOptions
extends
ComponentProps<'aside'>,
Pick<ComponentProps<typeof Sidebar>, 'defaultOpenLevel' | 'prefetch'> {
enabled?: boolean;
component?: ReactNode;
components?: Partial<SidebarPageTreeComponents>;
/**
* Root Toggle options
*/
tabs?: SidebarTabWithProps[] | GetSidebarTabsOptions | false;
banner?: ReactNode;
footer?: ReactNode;
}
export function DocsLayout({
tree,
nav = {},
sidebar: {
enabled: sidebarEnabled = true,
tabs: sidebarTabs,
defaultOpenLevel,
prefetch,
...sidebarProps
} = {},
searchToggle = {},
themeSwitch = {},
i18n = false,
children,
containerProps,
renderNavigationPanel = (props) => <NavigationPanel {...props} />,
...props
}: DocsLayoutProps) {
const tabs = useMemo(() => {
if (Array.isArray(sidebarTabs)) {
return sidebarTabs;
}
if (typeof sidebarTabs === 'object') {
return getSidebarTabs(tree, sidebarTabs);
}
if (sidebarTabs !== false) {
return getSidebarTabs(tree);
}
return [];
}, [tree, sidebarTabs]);
const { menuItems } = useLinkItems(props);
const iconLinks = menuItems.filter((item) => item.type === 'icon');
function sidebar() {
const { footer, banner, component, components, ...rest } = sidebarProps;
if (component) return component;
return (
<SidebarContent {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2 empty:hidden">{banner}</div>
<SidebarViewport>
{menuItems
.filter((v) => v.type !== 'icon')
.map((item, i, list) => (
<SidebarLinkItem
key={i}
item={item}
className={cn(i === list.length - 1 && 'mb-4')}
/>
))}
<SidebarPageTree {...components} />
</SidebarViewport>
{footer}
</SidebarContent>
);
}
return (
<TreeContextProvider tree={tree}>
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<div
id="nd-flux-layout"
{...containerProps}
className={cn(
'flex flex-col items-center pb-24 overflow-x-clip',
containerProps?.className,
)}
>
{sidebarEnabled && sidebar()}
{children}
</div>
{renderNavigationPanel({
head: renderTitleNav(nav, {
className: 'inline-flex items-center gap-2.5 text-sm font-semibold',
}),
tabDropdown: tabs.length > 0 && <SidebarTabsDropdown className="flex-1" options={tabs} />,
tool: (
<>
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
</LanguageToggle>
)}
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="rounded-lg" hideIfDisabled />
))}
<NavigationSidebarTrigger />
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle className="p-0 ms-1" mode={themeSwitch.mode} />
))}
</>
),
link: iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(buttonVariants({ size: 'icon-sm', color: 'ghost' }))}
aria-label={item.label}
>
{item.icon}
</LinkItem>
)),
})}
</Sidebar>
</TreeContextProvider>
);
}
function NavigationSidebarTrigger() {
const { open, setOpen } = useSidebar();
return (
<button
className={cn(
buttonVariants({
variant: 'ghost',
size: 'icon-sm',
className: 'overflow-hidden',
}),
)}
onClick={() => setOpen((prev) => !prev)}
>
<AnimatePresence mode="wait">
<motion.span
key={open ? 'open' : 'closed'}
transition={{ duration: 0.2 }}
initial={{
y: '100%',
opacity: 0,
}}
animate={{
y: 0,
opacity: 1,
}}
exit={{
y: '100%',
opacity: 0,
}}
>
{open ? <XIcon /> : <SidebarIcon />}
</motion.span>
</AnimatePresence>
</button>
);
}
export interface NavigationPanelProps {
head: ReactNode;
tabDropdown: ReactNode;
tool: ReactNode;
link: ReactNode;
}
export function NavigationPanel({
head,
tabDropdown,
tool,
link,
className,
children = (v) => v,
...props
}: NavigationPanelProps &
Omit<ComponentProps<typeof motion.div>, 'children'> & {
/**
* replace default children
*/
children?: (defaultChildren: ReactNode) => ReactNode;
}) {
return (
<motion.div
className={cn(
'fixed left-1/2 w-[calc(100%-var(--removed-body-scroll-bar-size,0px))] translate-x-[calc(-50%-var(--removed-body-scroll-bar-size,0px)/2)] bottom-0 z-40 bg-fd-popover text-fd-popover-foreground border-t shadow-lg sm:bottom-6 sm:rounded-2xl sm:border sm:max-w-[380px]',
className,
)}
{...props}
>
{children(
<>
<div className="flex flex-row items-center ps-2.5 p-1 gap-2 min-h-11">
{head}
<div id="flux-layout-slot" className="flex-1" />
</div>
<div className="flex flex-row items-center gap-1.5 overflow-x-auto overflow-y-hidden p-2 sm:p-1">
<div className="flex flex-row items-center gap-2 flex-1 empty:hidden">
{tabDropdown}
</div>
<div className="flex flex-row items-center text-fd-muted-foreground border-x px-0.5 empty:hidden">
{link}
</div>
<div className="flex flex-row items-center text-fd-muted-foreground empty:hidden">
{tool}
</div>
</div>
</>,
)}
</motion.div>
);
}
export function NavigationPanelOverlay({
enabled = false,
className,
...props
}: ComponentProps<typeof motion.div> & { enabled?: boolean }) {
return (
<RemoveScroll enabled={enabled}>
<motion.div
className={cn(
'fixed inset-0 z-30 pr-(--removed-body-scroll-bar-size,0) backdrop-blur-md bg-fd-background/60',
!enabled && 'pointer-events-none',
className,
)}
initial="hide"
variants={{
show: {
opacity: 1,
},
hide: {
opacity: 0,
},
}}
animate={enabled ? 'show' : 'hide'}
exit="hide"
{...props}
/>
</RemoveScroll>
);
}
@@ -0,0 +1,367 @@
'use client';
import {
type ComponentProps,
createContext,
Fragment,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import Link from 'fumadocs-core/link';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import type * as PageTree from 'fumadocs-core/page-tree';
import { usePathname } from 'fumadocs-core/framework';
import { type BreadcrumbOptions, getBreadcrumbItemsFromPath } from 'fumadocs-core/breadcrumb';
import { isActive } from '@/utils/urls';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useTOCItems } from '@/components/toc';
import { useActiveAnchor } from 'fumadocs-core/toc';
import { useFooterItems } from '@/utils/use-footer-items';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'motion/react';
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export function PageTOCPopover(props: ComponentProps<'div'>) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
const element = document.getElementById('flux-layout-slot');
if (!element) return;
setContainer(element);
}, []);
if (!container) return;
return createPortal(<PageTOCPopoverPhysical {...props} />, container);
}
function PageTOCPopoverPhysical({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn('h-9 animate-fd-fade-in', className)}
{...rest}
>
<header
ref={ref}
className={cn(
'border rounded-xl transition-colors bg-fd-secondary text-fd-secondary-foreground translate-y-[calc(-100%+--spacing(9.5))]',
open && 'shadow-lg bg-fd-popover/80 text-fd-popover-foreground backdrop-blur-sm',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
export function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = useTOCItems();
const active = useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const spanProps = {
transition: {
duration: 0.1,
},
initial: {
opacity: 0,
y: 10,
},
animate: {
opacity: 1,
y: 0,
},
exit: {
opacity: 0,
y: -10,
},
className: cn(open && 'text-fd-popover-foreground'),
};
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-9 items-center text-sm text-fd-muted-foreground gap-2.5 px-2 text-start focus-visible:outline-none [&_svg]:size-4',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<AnimatePresence mode="wait">
{items[selected] && selected !== -1 && !open ? (
<motion.span key={selected} {...spanProps}>
{items[selected].title}
</motion.span>
) : path ? (
<motion.span key={path.$id ?? ':pathId'} {...spanProps}>
{path.name}
</motion.span>
) : (
<motion.span key=":toc" {...spanProps}>
{text.toc}
</motion.span>
)}
</AnimatePresence>
<ChevronDown className={cn('ms-auto shrink-0 transition-transform', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
export function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-2 max-h-[50vh]', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
export function PageLastUpdate({
date: value,
...props
}: Omit<ComponentProps<'p'>, 'children'> & { date: Date }) {
const { text } = useI18n();
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)}>
{text.lastUpdate} {date}
</p>
);
}
type Item = Pick<PageTree.Item, 'name' | 'description' | 'url'>;
export interface FooterProps extends ComponentProps<'div'> {
/**
* Items including information for the next and previous page
*/
items?: {
previous?: Item;
next?: Item;
};
}
export function PageFooter({ 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, false));
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 { text } = useI18n();
const Icon = index === 0 ? ChevronLeft : ChevronRight;
return (
<Link
href={item.url}
className={cn(
'flex flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground @max-lg:col-span-full',
index === 1 && 'text-end',
)}
>
<div
className={cn(
'inline-flex items-center gap-1.5 font-medium',
index === 1 && 'flex-row-reverse',
)}
>
<Icon className="-mx-1 size-4 shrink-0 rtl:rotate-180" />
<p>{item.name}</p>
</div>
<p className="text-fd-muted-foreground truncate">
{item.description ?? (index === 0 ? text.previousPage : text.nextPage)}
</p>
</Link>
);
}
export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>;
export function PageBreadcrumb({
includeRoot,
includeSeparator,
includePage,
...props
}: BreadcrumbProps) {
const path = useTreePath();
const { root } = useTreeContext();
const items = useMemo(() => {
return getBreadcrumbItemsFromPath(root, path, {
includePage,
includeSeparator,
includeRoot,
});
}, [includePage, includeRoot, includeSeparator, path, root]);
if (items.length === 0) return null;
return (
<div
{...props}
className={cn('flex items-center gap-1.5 text-sm text-fd-muted-foreground', props.className)}
>
{items.map((item, i) => {
const className = cn('truncate', i === items.length - 1 && 'text-fd-primary font-medium');
return (
<Fragment key={i}>
{i !== 0 && <ChevronRight className="size-3.5 shrink-0" />}
{item.url ? (
<Link
href={item.url}
className={cn(className, 'transition-opacity hover:opacity-80')}
>
{item.name}
</Link>
) : (
<span className={className}>{item.name}</span>
)}
</Fragment>
);
})}
</div>
);
}
@@ -0,0 +1,190 @@
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { Edit } from 'lucide-react';
import { I18nLabel } from '@/contexts/i18n';
import {
type BreadcrumbProps,
type FooterProps,
PageBreadcrumb,
PageFooter,
PageTOCPopover,
PageTOCPopoverContent,
PageTOCPopoverTrigger,
} from './client';
import type { AnchorProviderProps, TOCItemType } from 'fumadocs-core/toc';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCProvider, TOCScrollArea } from '@/components/toc';
interface BreadcrumbOptions extends BreadcrumbProps {
enabled: boolean;
component: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled: boolean;
component: ReactNode;
}
export interface DocsPageProps {
toc?: TOCItemType[];
tableOfContent?: Partial<TableOfContentOptions>;
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
/**
* Replace or disable breadcrumb
*/
breadcrumb?: Partial<BreadcrumbOptions>;
/**
* Footer navigation, located under the page body.
*
* You can specify `footer.children` to add extra components under the footer.
*/
footer?: Partial<FooterOptions>;
children?: ReactNode;
/**
* Apply class names to the `#nd-page` container.
*/
className?: string;
}
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'> {
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
enabled: boolean;
component: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function DocsPage({
breadcrumb: { enabled: breadcrumbEnabled = true, component: breadcrumb, ...breadcrumbProps } = {},
footer: { enabled: footerEnabled, component: footerReplace, ...footerProps } = {},
full = false,
tableOfContent: { enabled: tocPopoverEnabled, component: tocPopover, ...tocOptions } = {},
toc = [],
children,
className,
}: DocsPageProps) {
tocPopoverEnabled ??=
toc.length > 0 || tocOptions.header !== undefined || tocOptions.footer !== undefined;
let wrapper = (children: ReactNode) => children;
if (tocPopoverEnabled) {
wrapper = (children) => (
<TOCProvider single={tocOptions.single} toc={toc}>
{children}
</TOCProvider>
);
}
return wrapper(
<>
{tocPopoverEnabled &&
(tocPopover ?? (
<PageTOCPopover>
<PageTOCPopoverContent>
{tocOptions.header}
<TOCScrollArea>
{tocOptions.style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{tocOptions.footer}
</PageTOCPopoverContent>
<PageTOCPopoverTrigger />
</PageTOCPopover>
))}
<article
id="nd-page"
data-full={full}
className={cn(
'flex flex-col w-full max-w-[900px] mx-auto [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14',
full ? 'max-w-[1200px]' : 'xl:layout:[--fd-toc-width:268px]',
className,
)}
>
{breadcrumbEnabled && (breadcrumb ?? <PageBreadcrumb {...breadcrumbProps} />)}
{children}
{footerEnabled !== false && (footerReplace ?? <PageFooter {...footerProps} />)}
</article>
</>,
);
}
export function EditOnGitHub(props: ComponentProps<'a'>) {
return (
<a
target="_blank"
rel="noreferrer noopener"
{...props}
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'gap-1.5 not-prose',
}),
props.className,
)}
>
{props.children ?? (
<>
<Edit className="size-3.5" />
<I18nLabel label="editOnGithub" />
</>
)}
</a>
);
}
/**
* Add typography styles
*/
export function DocsBody({ children, className, ...props }: ComponentProps<'div'>) {
return (
<div {...props} className={cn('prose flex-1', className)}>
{children}
</div>
);
}
export function DocsDescription({ children, className, ...props }: ComponentProps<'p'>) {
// Don't render if no description provided
if (children === undefined) return null;
return (
<p {...props} className={cn('mb-8 text-lg text-fd-muted-foreground', className)}>
{children}
</p>
);
}
export function DocsTitle({ children, className, ...props }: ComponentProps<'h1'>) {
return (
<h1 {...props} className={cn('text-[1.75em] font-semibold', className)}>
{children}
</h1>
);
}
export { PageLastUpdate, PageBreadcrumb } from './client';
@@ -0,0 +1,278 @@
'use client';
import * as Base from '@/components/sidebar/base';
import { cn } from '@/utils/cn';
import { type ComponentProps, useEffect, useEffectEvent, useRef } from 'react';
import { cva } from 'class-variance-authority';
import { createPageTreeRenderer } from '@/components/sidebar/page-tree';
import { createLinkItemRenderer } from '@/components/sidebar/link-item';
import { mergeRefs } from '@/utils/merge-refs';
import { motion } from 'motion/react';
import { RemoveScroll } from 'react-remove-scroll';
const MotionSidebarItem = motion.create(Base.SidebarItem);
const MotionSidebarFolderTrigger = motion.create(Base.SidebarFolderTrigger);
const MotionSidebarFolderLink = motion.create(Base.SidebarFolderLink);
const MotionSidebarFolderContent = motion.create(Base.SidebarFolderContent);
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
link: 'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors',
button:
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
},
highlight: {
true: "data-[active=true]:before:content-[''] data-[active=true]:before:bg-fd-primary data-[active=true]:before:absolute data-[active=true]:before:w-px data-[active=true]:before:inset-y-2.5 data-[active=true]:before:start-2.5",
},
},
},
);
function getItemOffset(depth: number) {
return `calc(${2 + 3 * depth} * var(--spacing))`;
}
export function Sidebar(props: ComponentProps<typeof Base.SidebarProvider>) {
return <Base.SidebarProvider {...props} />;
}
export function SidebarFolder(props: ComponentProps<typeof Base.SidebarFolder>) {
return <Base.SidebarFolder {...props} />;
}
export function SidebarCollapseTrigger(props: ComponentProps<typeof Base.SidebarCollapseTrigger>) {
return <Base.SidebarCollapseTrigger {...props} />;
}
export function SidebarViewport(props: ComponentProps<typeof Base.SidebarViewport>) {
return <Base.SidebarViewport {...props} />;
}
export function SidebarTrigger(props: ComponentProps<typeof Base.SidebarTrigger>) {
return <Base.SidebarTrigger {...props} />;
}
export function SidebarContent({
ref: refProp,
className,
children,
...props
}: ComponentProps<'aside'>) {
const ref = useRef<HTMLElement>(null);
const { open, setOpen } = Base.useSidebar();
const listener = useEffectEvent((e: KeyboardEvent) => {
if (open && e.key === 'Escape') {
setOpen(false);
e.preventDefault();
}
});
useEffect(() => {
window.addEventListener('keydown', listener);
return () => {
window.removeEventListener('keydown', listener);
};
}, []);
return (
<RemoveScroll enabled={open}>
<motion.div
className={cn(
'fixed inset-0 py-10 z-30 backdrop-blur-md bg-fd-background/60',
!open && 'pointer-events-none',
)}
initial="hide"
variants={{
show: {
opacity: 1,
},
hide: {
opacity: 0,
},
}}
animate={open ? 'show' : 'hide'}
exit="hide"
onClick={() => {
setOpen(false);
}}
>
<motion.div
className="absolute overflow-y-auto pr-(--removed-body-scroll-bar-size,0) [scrollbar-width:none] py-16 inset-0 bottom-26 overscroll-contain mask-[linear-gradient(to_bottom,transparent,white_calc(var(--spacing)*14),white_calc(100%-var(--spacing)*14),transparent)] lg:text-sm"
variants={{
show: {
y: 0,
opacity: 1,
},
hide: {
y: '80%',
opacity: 0,
},
}}
transition={{
duration: 0.3,
ease: [0.16, 1, 0.3, 1],
opacity: {
duration: 0.1,
},
}}
>
<motion.aside
id="nd-sidebar"
ref={mergeRefs(ref, refProp)}
className={cn('mx-auto sm:max-w-[400px]', className)}
onClick={(e) => e.stopPropagation()}
{...(props as ComponentProps<typeof motion.aside>)}
>
{children}
</motion.aside>
</motion.div>
</motion.div>
</RemoveScroll>
);
}
export function SidebarSeparator({ className, style, children, ...props }: ComponentProps<'p'>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarSeparator
className={cn('[&_svg]:size-4 [&_svg]:shrink-0', className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
>
{children}
</Base.SidebarSeparator>
);
}
export function SidebarItem({
className,
style,
children,
...props
}: ComponentProps<typeof Base.SidebarItem>) {
const depth = Base.useFolderDepth();
return (
<MotionSidebarItem
className={cn(itemVariants({ variant: 'link', highlight: depth >= 1 }), className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...(props as object)}
>
{children}
</MotionSidebarItem>
);
}
export function SidebarFolderTrigger({
className,
style,
...props
}: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const { depth, collapsible } = Base.useFolder()!;
return (
<MotionSidebarFolderTrigger
className={(s) =>
cn(
itemVariants({ variant: collapsible ? 'button' : null }),
'w-full',
typeof className === 'function' ? className(s) : className,
)
}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
{...(props as ComponentProps<typeof MotionSidebarFolderTrigger>)}
>
{props.children}
</MotionSidebarFolderTrigger>
);
}
export function SidebarFolderLink({
className,
style,
...props
}: ComponentProps<typeof Base.SidebarFolderLink>) {
const depth = Base.useFolderDepth();
return (
<MotionSidebarFolderLink
className={cn(itemVariants({ variant: 'link', highlight: depth > 1 }), 'w-full', className)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
{...(props as ComponentProps<typeof MotionSidebarFolderLink>)}
>
{props.children}
</MotionSidebarFolderLink>
);
}
export function SidebarFolderContent({
className,
children,
...props
}: ComponentProps<typeof Base.SidebarFolderContent>) {
const depth = Base.useFolderDepth();
const { open } = Base.useFolder()!;
return (
<MotionSidebarFolderContent
className={(s) =>
cn(
'relative',
depth === 1 &&
"before:content-[''] before:absolute before:w-px before:inset-y-1 before:bg-fd-border before:start-2.5",
typeof className === 'function' ? className(s) : className,
)
}
{...(props as ComponentProps<typeof MotionSidebarFolderContent>)}
>
<motion.div
initial="hide"
animate={open ? 'show' : 'hide'}
exit="hide"
variants={{
show: {
opacity: 1,
},
hide: {
opacity: 0,
},
}}
>
{children}
</motion.div>
</MotionSidebarFolderContent>
);
}
export const SidebarPageTree = createPageTreeRenderer({
SidebarFolder,
SidebarFolderContent,
SidebarSeparator,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
});
export const SidebarLinkItem = createLinkItemRenderer({
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
});
@@ -0,0 +1,106 @@
'use client';
import { Check, ChevronsUpDown } from 'lucide-react';
import { type ComponentProps, type ReactNode, useMemo, useState } from 'react';
import Link from 'fumadocs-core/link';
import { usePathname } from 'fumadocs-core/framework';
import { cn } from '@/utils/cn';
import { isActive, normalize } from '@/utils/urls';
import { useSidebar } from '@/components/sidebar/base';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { SidebarTab } from '@/components/sidebar/tabs';
export interface SidebarTabWithProps extends SidebarTab {
props?: ComponentProps<'a'>;
}
export function SidebarTabsDropdown({
options,
placeholder,
className,
...props
}: {
placeholder?: ReactNode;
options: SidebarTabWithProps[];
} & ComponentProps<'button'>) {
const [open, setOpen] = useState(false);
const { closeOnRedirect } = useSidebar();
const pathname = usePathname();
const selected = useMemo(() => {
return options.findLast((item) => isTabActive(item, pathname));
}, [options, pathname]);
const onClick = () => {
closeOnRedirect.current = false;
setOpen(false);
};
const item = selected ? (
<>
<div className="size-5 shrink-0 empty:hidden">{selected.icon}</div>
<p className="font-medium">{selected.title}</p>
</>
) : (
placeholder
);
return (
<Popover open={open} onOpenChange={setOpen}>
{item && (
<PopoverTrigger
className={cn(
'flex items-center gap-2 rounded-full p-1.5 border shadow-sm text-sm text-start transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground data-open:bg-fd-accent data-open:text-fd-accent-foreground',
className,
)}
{...props}
>
{item}
<ChevronsUpDown className="shrink-0 ms-auto size-4 text-fd-muted-foreground" />
</PopoverTrigger>
)}
<PopoverContent
align="start"
className="flex flex-col gap-1 max-w-svw p-1 fd-scroll-container"
>
{options.map((item) => {
const isActive = selected && item.url === selected.url;
if (!isActive && item.unlisted) return;
return (
<Link
key={item.url}
href={item.url}
onClick={onClick}
{...item.props}
className={cn(
'flex items-center gap-2 rounded-lg p-1.5 hover:bg-fd-accent hover:text-fd-accent-foreground',
item.props?.className,
)}
>
<div className="shrink-0 mb-auto size-5 empty:hidden">{item.icon}</div>
<div>
<p className="text-sm font-medium leading-none">{item.title}</p>
<p className="text-[0.8125rem] text-fd-muted-foreground mt-1 empty:hidden">
{item.description}
</p>
</div>
<Check
className={cn(
'shrink-0 ms-auto size-3.5 text-fd-primary',
!isActive && 'invisible',
)}
/>
</Link>
);
})}
</PopoverContent>
</Popover>
);
}
export function isTabActive(tab: SidebarTab, pathname: string) {
if (tab.urls) return tab.urls.has(normalize(pathname));
return isActive(tab.url, pathname, true);
}
+12 -5
View File
@@ -1003,7 +1003,7 @@ importers:
version: 19.2.3(@types/[email protected])
'@vitejs/plugin-rsc':
specifier: ^0.5.17
version: 0.5.17([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
version: 0.5.17([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
react-router-devtools:
specifier: ^6.2.0
version: 6.2.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
@@ -1528,6 +1528,9 @@ importers:
lucide-react:
specifier: ^0.563.0
version: 0.563.0([email protected])
motion:
specifier: ^12.30.1
version: 12.30.1([email protected]([email protected]))([email protected])
next:
specifier: 16.x.x
version: 16.1.6(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
@@ -1543,6 +1546,9 @@ importers:
react-medium-image-zoom:
specifier: ^5.4.0
version: 5.4.0([email protected]([email protected]))([email protected])
react-remove-scroll:
specifier: ^2.7.2
version: 2.7.2(@types/[email protected])([email protected])
scroll-into-view-if-needed:
specifier: ^3.1.0
version: 3.1.0
@@ -2319,7 +2325,7 @@ importers:
version: 5.1.2([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc':
specifier: ^0.5.17
version: 0.5.17([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
version: 0.5.17([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
commander:
specifier: ^14.0.2
version: 14.0.2
@@ -7084,6 +7090,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-Tn98GlZVN2WM7+lg/uGn5DzUao37Yc0PUz7yzYHdeF5hd+SmHQGbCUIKE4Sspdgtxn49LunK3mDNBC2Qn6GJjw==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@@ -14545,7 +14552,7 @@ snapshots:
vite-node: 3.2.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
optionalDependencies:
'@react-router/serve': 7.13.0([email protected]([email protected]([email protected]))([email protected]))([email protected])
'@vitejs/plugin-rsc': 0.5.17([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.17([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
react-server-dom-webpack: 19.2.4([email protected]([email protected]))([email protected])([email protected])
typescript: 5.9.3
transitivePeerDependencies:
@@ -16039,7 +16046,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/[email protected]([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
'@vitejs/[email protected]([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
es-module-lexer: 2.0.0
estree-walker: 3.0.3
@@ -22206,7 +22213,7 @@ snapshots:
dependencies:
'@hono/node-server': 1.19.9([email protected])
'@vitejs/plugin-react': 5.1.2([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.17([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.17([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
dotenv: 17.2.3
hono: 4.11.7
magic-string: 0.30.21