mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: include Flux layout to Base UI
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@fumadocs/base-ui': patch
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
support Flux layout
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
"@fumadocs/tailwind": patch
|
||||
'@fumadocs/tailwind': patch
|
||||
---
|
||||
|
||||
initial release
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@source '../../dist/layouts/flux/**/*.js';
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+12
-5
Reference in New Issue
Block a user