mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 06:31:49 +00:00
UI: Move sidebar toolbar to top on mobile view
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
Move sidebar toolbar to top on mobile view
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
@@ -29,7 +29,11 @@ export function HideIfEmptyProvider({
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = (node: HTMLElement) => {
|
||||
function getElement(id: string) {
|
||||
return document.querySelector<HTMLElement>(`[data-fd-if-empty="${id}"]`);
|
||||
}
|
||||
|
||||
function isEmpty(node: HTMLElement) {
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const child = node.childNodes.item(i);
|
||||
|
||||
@@ -43,7 +47,7 @@ const isEmpty = (node: HTMLElement) => {
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in CSS `:empty` selector cannot detect if the children is hidden, classes such as `md:hidden` causes it to fail.
|
||||
@@ -51,47 +55,49 @@ const isEmpty = (node: HTMLElement) => {
|
||||
*
|
||||
* This can be expensive, please avoid this whenever possible.
|
||||
*/
|
||||
export function HideIfEmpty({ children }: { children: ReactNode }) {
|
||||
export function HideIfEmpty<Props extends HTMLAttributes<HTMLElement>>({
|
||||
as: Comp,
|
||||
...props
|
||||
}: Props & {
|
||||
as: FC<Props>;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [empty, setEmpty] = useState<boolean | undefined>();
|
||||
const { nonce } = useContext(Context);
|
||||
const [empty, setEmpty] = useState(() => {
|
||||
const element = typeof window !== 'undefined' ? getElement(id) : null;
|
||||
if (element) return isEmpty(element);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const element = document.querySelector(`[data-fd-if-empty="${id}"]`);
|
||||
if (!element || !(element instanceof HTMLElement)) return;
|
||||
setEmpty(isEmpty(element));
|
||||
const element = getElement(id);
|
||||
if (element) setEmpty(isEmpty(element));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [id]);
|
||||
|
||||
let child;
|
||||
if (isValidElement(children)) {
|
||||
child = cloneElement(children, {
|
||||
...(children.props as object),
|
||||
'data-fd-if-empty': id,
|
||||
'data-empty': empty,
|
||||
suppressHydrationWarning: true,
|
||||
} as object);
|
||||
} else {
|
||||
throw new Error('expected to receive a single React element child.');
|
||||
}
|
||||
const init = (id: string) => {
|
||||
const element = getElement(id);
|
||||
if (element) element.hidden = isEmpty(element);
|
||||
|
||||
const script = document.currentScript;
|
||||
if (script) script.parentNode?.removeChild(script);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{child}
|
||||
<Comp
|
||||
{...(props as unknown as Props)}
|
||||
data-fd-if-empty={id}
|
||||
hidden={empty ?? false}
|
||||
/>
|
||||
{empty === undefined && (
|
||||
<script
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `{
|
||||
const element = document.querySelector('[data-fd-if-empty="${id}"]')
|
||||
if (element) {
|
||||
element.setAttribute('data-empty', String((${isEmpty.toString()})(element)))
|
||||
}}`,
|
||||
__html: `{${getElement};${isEmpty};(${init})("${id}")}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { ChevronDown, ExternalLink, X } from 'lucide-react';
|
||||
import { ChevronDown, ExternalLink } from 'lucide-react';
|
||||
import { usePathname } from 'fumadocs-core/framework';
|
||||
import {
|
||||
type ComponentProps,
|
||||
@@ -33,9 +33,8 @@ import type { PageTree } from 'fumadocs-core/server';
|
||||
import { useTreeContext, useTreePath } from '@/contexts/tree';
|
||||
import { useMediaQuery } from 'fumadocs-core/utils/use-media-query';
|
||||
import { Presence } from '@radix-ui/react-presence';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
export interface SidebarProps extends ComponentProps<'aside'> {
|
||||
export interface SidebarProps {
|
||||
/**
|
||||
* Open folders by default if their level is lower or equal to a specific level
|
||||
* (Starting from 1)
|
||||
@@ -52,11 +51,14 @@ export interface SidebarProps extends ComponentProps<'aside'> {
|
||||
prefetch?: boolean;
|
||||
|
||||
/**
|
||||
* Support collapsing the sidebar on desktop mode
|
||||
*
|
||||
* @defaultValue true
|
||||
* Children to render
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
Content: ReactNode;
|
||||
|
||||
/**
|
||||
* Alternative children for mobile
|
||||
*/
|
||||
Mobile?: ReactNode;
|
||||
}
|
||||
|
||||
interface InternalContext {
|
||||
@@ -87,10 +89,10 @@ const FolderContext = createContext<{
|
||||
export function Sidebar({
|
||||
defaultOpenLevel = 0,
|
||||
prefetch = true,
|
||||
collapsible = true,
|
||||
...props
|
||||
Mobile,
|
||||
Content,
|
||||
}: SidebarProps) {
|
||||
const { open, setOpen, collapsed } = useSidebar();
|
||||
const isMobile = useMediaQuery('(width < 768px)') ?? false;
|
||||
const context = useMemo<InternalContext>(() => {
|
||||
return {
|
||||
defaultOpenLevel,
|
||||
@@ -99,62 +101,24 @@ export function Sidebar({
|
||||
};
|
||||
}, [defaultOpenLevel, prefetch]);
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
{isMobile && Mobile != null ? Mobile : Content}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarContent(props: ComponentProps<'aside'>) {
|
||||
const { collapsed } = useSidebar();
|
||||
const [hover, setHover] = useState(false);
|
||||
const timerRef = useRef(0);
|
||||
const closeTimeRef = useRef(0);
|
||||
// md
|
||||
const isMobile = useMediaQuery('(width < 768px)') ?? false;
|
||||
|
||||
useOnChange(collapsed, () => {
|
||||
setHover(false);
|
||||
closeTimeRef.current = Date.now() + 150;
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
const state = open ? 'open' : 'closed';
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<Presence present={open}>
|
||||
<div
|
||||
data-state={state}
|
||||
className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</Presence>
|
||||
<Presence present={open}>
|
||||
{({ present }) => (
|
||||
<aside
|
||||
id="nd-sidebar-mobile"
|
||||
{...props}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
'fixed text-[15px] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
|
||||
!present && 'invisible',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className:
|
||||
'mt-3 mb-1 ms-auto me-4 text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
{props.children}
|
||||
</aside>
|
||||
)}
|
||||
</Presence>
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="nd-sidebar"
|
||||
@@ -164,7 +128,7 @@ export function Sidebar({
|
||||
'fixed start-0 flex flex-col items-end top-(--fd-sidebar-top) bottom-(--fd-sidebar-margin) z-20 bg-fd-card text-sm border-e max-md:hidden *:w-(--fd-sidebar-width)',
|
||||
collapsed && [
|
||||
'rounded-xl border translate-x-(--fd-sidebar-offset) rtl:-translate-x-(--fd-sidebar-offset)',
|
||||
hover ? 'z-50 shadow-lg' : 'opacity-0 ',
|
||||
hover ? 'z-50 shadow-lg' : 'opacity-0',
|
||||
],
|
||||
props.className,
|
||||
)}
|
||||
@@ -186,7 +150,6 @@ export function Sidebar({
|
||||
}
|
||||
onPointerEnter={(e) => {
|
||||
if (
|
||||
!collapsible ||
|
||||
!collapsed ||
|
||||
e.pointerType === 'touch' ||
|
||||
closeTimeRef.current > Date.now()
|
||||
@@ -196,7 +159,7 @@ export function Sidebar({
|
||||
setHover(true);
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (!collapsible || !collapsed || e.pointerType === 'touch') return;
|
||||
if (!collapsed || e.pointerType === 'touch') return;
|
||||
window.clearTimeout(timerRef.current);
|
||||
|
||||
timerRef.current = window.setTimeout(
|
||||
@@ -210,16 +173,53 @@ export function Sidebar({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Context.Provider value={context}>{props.children}</Context.Provider>
|
||||
{props.children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarContentMobile({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<'aside'>) {
|
||||
const { open, setOpen } = useSidebar();
|
||||
const state = open ? 'open' : 'closed';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Presence present={open}>
|
||||
<div
|
||||
data-state={state}
|
||||
className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</Presence>
|
||||
<Presence present={open}>
|
||||
{({ present }) => (
|
||||
<aside
|
||||
id="nd-sidebar-mobile"
|
||||
{...props}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
'fixed text-[15px] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
|
||||
!present && 'invisible',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
)}
|
||||
</Presence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarHeader(props: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex flex-col gap-3 px-4 pb-2 md:pt-4', props.className)}
|
||||
className={cn('flex flex-col gap-3 p-4 pb-2', props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import type { PageTree } from 'fumadocs-core/server';
|
||||
import { type HTMLAttributes, type ReactNode, useMemo } from 'react';
|
||||
import {
|
||||
type ComponentProps,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Languages, Sidebar as SidebarIcon } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarCollapseTrigger,
|
||||
SidebarContent,
|
||||
SidebarContentMobile,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarPageTree,
|
||||
SidebarViewport,
|
||||
} from '@/components/layout/sidebar';
|
||||
import { BaseLinkItem, type LinkItemType } from '@/layouts/links';
|
||||
import {
|
||||
BaseLinkItem,
|
||||
type IconItemType,
|
||||
type LinkItemType,
|
||||
} from '@/layouts/links';
|
||||
import { RootToggle } from '@/components/layout/root-toggle';
|
||||
import { type BaseLayoutProps, getLinks } from './shared';
|
||||
import {
|
||||
@@ -42,10 +53,11 @@ import { HideIfEmpty } from 'fumadocs-core/hide-if-empty';
|
||||
export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
tree: PageTree.Root;
|
||||
|
||||
sidebar?: Partial<SidebarOptions> & {
|
||||
enabled?: boolean;
|
||||
component?: ReactNode;
|
||||
};
|
||||
sidebar?: SidebarOptions &
|
||||
ComponentProps<'aside'> & {
|
||||
enabled?: boolean;
|
||||
component?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for the `div` container
|
||||
@@ -57,12 +69,7 @@ export function DocsLayout({
|
||||
nav: { transparentMode, ...nav } = {},
|
||||
sidebar: {
|
||||
tabs: sidebarTabs,
|
||||
footer: sidebarFooter,
|
||||
banner: sidebarBanner,
|
||||
enabled: sidebarEnabled = true,
|
||||
collapsible: sidebarCollapsible = true,
|
||||
component: sidebarComponent,
|
||||
components: sidebarComponents,
|
||||
...sidebarProps
|
||||
} = {},
|
||||
searchToggle = {},
|
||||
@@ -87,92 +94,129 @@ export function DocsLayout({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const sidebar = sidebarComponent ?? (
|
||||
<>
|
||||
{sidebarCollapsible && <CollapsibleControl />}
|
||||
<Sidebar {...sidebarProps} collapsible={sidebarCollapsible}>
|
||||
<HideIfEmpty>
|
||||
<SidebarHeader className="data-[empty=true]:hidden">
|
||||
<div className="flex max-md:hidden">
|
||||
<Link
|
||||
href={nav.url ?? '/'}
|
||||
className="inline-flex text-[15px] items-center gap-2.5 font-medium me-auto"
|
||||
>
|
||||
{nav.title}
|
||||
</Link>
|
||||
{nav.children}
|
||||
{sidebarCollapsible && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className:
|
||||
'mb-auto text-fd-muted-foreground max-md:hidden',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
</div>
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ?? (
|
||||
<LargeSearchToggle hideIfDisabled className="max-md:hidden" />
|
||||
))}
|
||||
{tabs.length > 0 && <RootToggle options={tabs} />}
|
||||
function sidebar() {
|
||||
const {
|
||||
footer,
|
||||
banner,
|
||||
collapsible = true,
|
||||
component,
|
||||
components,
|
||||
defaultOpenLevel,
|
||||
prefetch,
|
||||
...rest
|
||||
} = sidebarProps;
|
||||
if (component) return component;
|
||||
|
||||
{sidebarBanner}
|
||||
</SidebarHeader>
|
||||
const iconLinks = links.filter(
|
||||
(item): item is IconItemType => item.type === 'icon',
|
||||
);
|
||||
|
||||
const viewport = (
|
||||
<SidebarViewport>
|
||||
{links
|
||||
.filter((v) => v.type !== 'icon')
|
||||
.map((item, i, list) => (
|
||||
<SidebarLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(i === list.length - 1 && 'mb-4')}
|
||||
/>
|
||||
))}
|
||||
<SidebarPageTree components={components} />
|
||||
</SidebarViewport>
|
||||
);
|
||||
|
||||
const mobile = (
|
||||
<SidebarContentMobile {...rest}>
|
||||
<HideIfEmpty as={SidebarHeader}>
|
||||
<div className="flex items-center justify-end gap-2 px-0.5 empty:hidden">
|
||||
<IconItemList items={iconLinks} />
|
||||
{i18n ? (
|
||||
<LanguageToggle className="me-1.5">
|
||||
<Languages className="size-4.5" />
|
||||
<LanguageToggleText />
|
||||
</LanguageToggle>
|
||||
) : null}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle className="p-0" mode={themeSwitch.mode} />
|
||||
))}
|
||||
<NavbarSidebarTrigger className="text-fd-muted-foreground" />
|
||||
</div>
|
||||
{tabs.length > 0 && <RootToggle options={tabs} />}
|
||||
{banner}
|
||||
</HideIfEmpty>
|
||||
<SidebarViewport>
|
||||
{links
|
||||
.filter((v) => v.type !== 'icon')
|
||||
.map((item, i, list) => (
|
||||
<SidebarLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(i === list.length - 1 && 'mb-4')}
|
||||
/>
|
||||
{viewport}
|
||||
<SidebarFooter className="empty:hidden">{footer}</SidebarFooter>
|
||||
</SidebarContentMobile>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<SidebarContent {...rest}>
|
||||
<HideIfEmpty as={SidebarHeader}>
|
||||
<div className="flex">
|
||||
<Link
|
||||
href={nav.url ?? '/'}
|
||||
className="inline-flex text-[15px] items-center gap-2.5 font-medium me-auto"
|
||||
>
|
||||
{nav.title}
|
||||
</Link>
|
||||
{nav.children}
|
||||
{collapsible && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'ghost',
|
||||
size: 'icon-sm',
|
||||
className: 'mb-auto text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</SidebarCollapseTrigger>
|
||||
)}
|
||||
</div>
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.lg ?? (
|
||||
<LargeSearchToggle hideIfDisabled />
|
||||
))}
|
||||
<SidebarPageTree components={sidebarComponents} />
|
||||
</SidebarViewport>
|
||||
<HideIfEmpty>
|
||||
<SidebarFooter className="data-[empty=true]:hidden">
|
||||
<div className="flex items-center justify-end empty:hidden">
|
||||
{links
|
||||
.filter((item) => item.type === 'icon')
|
||||
.map((item, i, arr) => (
|
||||
<BaseLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
buttonVariants({ size: 'icon', color: 'ghost' }),
|
||||
'text-fd-muted-foreground md:[&_svg]:size-4.5',
|
||||
i === arr.length - 1 && 'me-auto',
|
||||
)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
))}
|
||||
{i18n ? (
|
||||
<LanguageToggle className="me-1.5">
|
||||
<Languages className="size-4.5" />
|
||||
<LanguageToggleText className="md:hidden" />
|
||||
</LanguageToggle>
|
||||
) : null}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle className="p-0" mode={themeSwitch.mode} />
|
||||
))}
|
||||
</div>
|
||||
{sidebarFooter}
|
||||
</SidebarFooter>
|
||||
{tabs.length > 0 && <RootToggle options={tabs} />}
|
||||
|
||||
{banner}
|
||||
</HideIfEmpty>
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
{viewport}
|
||||
<HideIfEmpty as={SidebarFooter}>
|
||||
<div className="flex items-center justify-end empty:hidden">
|
||||
<IconItemList items={iconLinks} />
|
||||
{i18n ? (
|
||||
<LanguageToggle className="me-1.5">
|
||||
<Languages className="size-4.5" />
|
||||
</LanguageToggle>
|
||||
) : null}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle className="p-0" mode={themeSwitch.mode} />
|
||||
))}
|
||||
</div>
|
||||
{footer}
|
||||
</HideIfEmpty>
|
||||
</SidebarContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
defaultOpenLevel={defaultOpenLevel}
|
||||
prefetch={prefetch}
|
||||
Mobile={mobile}
|
||||
Content={
|
||||
<>
|
||||
{collapsible && <CollapsibleControl />}
|
||||
{content}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeContextProvider tree={props.tree}>
|
||||
@@ -187,7 +231,7 @@ export function DocsLayout({
|
||||
{nav.title}
|
||||
</Link>
|
||||
<div className="flex-1">{nav.children}</div>
|
||||
{searchToggle?.enabled !== false &&
|
||||
{searchToggle.enabled !== false &&
|
||||
(searchToggle.components?.sm ?? (
|
||||
<SearchToggle className="p-2" hideIfDisabled />
|
||||
))}
|
||||
@@ -200,7 +244,7 @@ export function DocsLayout({
|
||||
{...props.containerProps}
|
||||
className={cn(variables, props.containerProps?.className)}
|
||||
>
|
||||
{sidebarEnabled && sidebar}
|
||||
{sidebarEnabled && sidebar()}
|
||||
{children}
|
||||
</LayoutBody>
|
||||
</NavProvider>
|
||||
@@ -208,4 +252,21 @@ export function DocsLayout({
|
||||
);
|
||||
}
|
||||
|
||||
function IconItemList({ items }: { items: IconItemType[] }) {
|
||||
return items.map((item, i) => (
|
||||
<BaseLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
buttonVariants({ size: 'icon', color: 'ghost' }),
|
||||
'text-fd-muted-foreground [&_svg]:size-4.5',
|
||||
i === items.length - 1 && 'me-auto',
|
||||
)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
));
|
||||
}
|
||||
|
||||
export { CollapsibleControl, Navbar, NavbarSidebarTrigger, type LinkItemType };
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
type GetSidebarTabsOptions,
|
||||
} from '@/utils/get-sidebar-tabs';
|
||||
|
||||
export interface SidebarOptions extends SidebarProps {
|
||||
export interface SidebarOptions
|
||||
extends Pick<SidebarProps, 'defaultOpenLevel' | 'prefetch'> {
|
||||
components?: Partial<SidebarComponents>;
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,13 @@ export interface SidebarOptions extends SidebarProps {
|
||||
|
||||
banner?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
|
||||
/**
|
||||
* Support collapsing the sidebar on desktop mode
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarLinkItem({
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Fragment, type HTMLAttributes, useMemo } from 'react';
|
||||
import {
|
||||
type ComponentProps,
|
||||
Fragment,
|
||||
type HTMLAttributes,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { type BaseLayoutProps, getLinks } from '@/layouts/shared';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarCollapseTrigger,
|
||||
SidebarContent,
|
||||
SidebarContentMobile,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarPageTree,
|
||||
@@ -49,7 +56,7 @@ export interface DocsLayoutProps extends BaseLayoutProps {
|
||||
mode?: 'top' | 'auto';
|
||||
};
|
||||
|
||||
sidebar?: Partial<SidebarOptions>;
|
||||
sidebar?: SidebarOptions & ComponentProps<'aside'>;
|
||||
|
||||
containerProps?: HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
@@ -63,6 +70,9 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
banner: sidebarBanner,
|
||||
footer: sidebarFooter,
|
||||
components: sidebarComponents,
|
||||
collapsible: sidebarCollapsible = true,
|
||||
prefetch,
|
||||
defaultOpenLevel,
|
||||
...sidebar
|
||||
} = {},
|
||||
i18n = false,
|
||||
@@ -90,7 +100,7 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
>
|
||||
{nav.title}
|
||||
</Link>
|
||||
{(sidebar.collapsible ?? true) && (
|
||||
{sidebarCollapsible && (
|
||||
<SidebarCollapseTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
@@ -106,6 +116,73 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const sidebarContent = (
|
||||
<>
|
||||
<HideIfEmpty as={SidebarHeader}>
|
||||
{navMode === 'auto' && sidebarHeader}
|
||||
{nav.children}
|
||||
{sidebarBanner}
|
||||
{tabMode === 'sidebar' && tabs.length > 0 ? (
|
||||
<RootToggle className="mb-2" options={tabs} />
|
||||
) : null}
|
||||
{tabMode === 'navbar' && tabs.length > 0 && (
|
||||
<RootToggle options={tabs} className="lg:hidden" />
|
||||
)}
|
||||
</HideIfEmpty>
|
||||
<SidebarViewport>
|
||||
{links
|
||||
.filter((item) => item.type !== 'icon')
|
||||
.map((item, i) => (
|
||||
<SidebarLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn('lg:hidden', i === links.length - 1 && 'mb-4')}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SidebarPageTree components={sidebarComponents} />
|
||||
</SidebarViewport>
|
||||
<HideIfEmpty
|
||||
as={SidebarFooter}
|
||||
className="flex flex-row items-center justify-end"
|
||||
>
|
||||
<div className="flex items-center flex-1 empty:hidden lg:hidden">
|
||||
{links
|
||||
.filter((item) => item.type === 'icon')
|
||||
.map((item, i) => (
|
||||
<BaseLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
color: 'ghost',
|
||||
className: 'text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
))}
|
||||
</div>
|
||||
{i18n ? (
|
||||
<LanguageToggle className="me-auto md:hidden">
|
||||
<Languages className="size-4.5 text-fd-muted-foreground" />
|
||||
</LanguageToggle>
|
||||
) : null}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle
|
||||
className="md:hidden"
|
||||
mode={themeSwitch?.mode ?? 'light-dark-system'}
|
||||
/>
|
||||
))}
|
||||
{sidebarFooter}
|
||||
</HideIfEmpty>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<TreeContextProvider tree={props.tree}>
|
||||
<NavProvider transparentMode={transparentMode}>
|
||||
@@ -114,81 +191,27 @@ export function DocsLayout(props: DocsLayoutProps) {
|
||||
className={cn(variables, props.containerProps?.className)}
|
||||
>
|
||||
<Sidebar
|
||||
{...sidebar}
|
||||
className={cn(
|
||||
navMode === 'top'
|
||||
? 'border-e-0 md:bg-transparent'
|
||||
: 'md:[--fd-nav-height:0px]',
|
||||
sidebar.className,
|
||||
)}
|
||||
>
|
||||
<HideIfEmpty>
|
||||
<SidebarHeader className="data-[empty=true]:hidden">
|
||||
{navMode === 'auto' && sidebarHeader}
|
||||
{nav.children}
|
||||
{sidebarBanner}
|
||||
{tabMode === 'sidebar' && tabs.length > 0 ? (
|
||||
<RootToggle className="mb-2" options={tabs} />
|
||||
) : null}
|
||||
{tabMode === 'navbar' && tabs.length > 0 && (
|
||||
<RootToggle options={tabs} className="lg:hidden" />
|
||||
defaultOpenLevel={defaultOpenLevel}
|
||||
prefetch={prefetch}
|
||||
Content={
|
||||
<SidebarContent
|
||||
{...sidebar}
|
||||
className={cn(
|
||||
navMode === 'top'
|
||||
? 'border-e-0 bg-transparent'
|
||||
: '[--fd-nav-height:0px]',
|
||||
sidebar.className,
|
||||
)}
|
||||
</SidebarHeader>
|
||||
</HideIfEmpty>
|
||||
<SidebarViewport>
|
||||
{links
|
||||
.filter((item) => item.type !== 'icon')
|
||||
.map((item, i) => (
|
||||
<SidebarLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
'lg:hidden',
|
||||
i === links.length - 1 && 'mb-4',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SidebarPageTree components={sidebarComponents} />
|
||||
</SidebarViewport>
|
||||
<HideIfEmpty>
|
||||
<SidebarFooter className="flex flex-row items-center justify-end data-[empty=true]:hidden">
|
||||
<div className="flex items-center flex-1 empty:hidden lg:hidden">
|
||||
{links
|
||||
.filter((item) => item.type === 'icon')
|
||||
.map((item, i) => (
|
||||
<BaseLinkItem
|
||||
key={i}
|
||||
item={item}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'icon-sm',
|
||||
color: 'ghost',
|
||||
className: 'text-fd-muted-foreground',
|
||||
}),
|
||||
)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</BaseLinkItem>
|
||||
))}
|
||||
</div>
|
||||
{i18n ? (
|
||||
<LanguageToggle className="me-auto md:hidden">
|
||||
<Languages className="size-4.5 text-fd-muted-foreground" />
|
||||
</LanguageToggle>
|
||||
) : null}
|
||||
{themeSwitch.enabled !== false &&
|
||||
(themeSwitch.component ?? (
|
||||
<ThemeToggle
|
||||
className="md:hidden"
|
||||
mode={themeSwitch?.mode ?? 'light-dark-system'}
|
||||
/>
|
||||
))}
|
||||
{sidebarFooter}
|
||||
</SidebarFooter>
|
||||
</HideIfEmpty>
|
||||
</Sidebar>
|
||||
>
|
||||
{sidebarContent}
|
||||
</SidebarContent>
|
||||
}
|
||||
Mobile={
|
||||
<SidebarContentMobile {...sidebar}>
|
||||
{sidebarContent}
|
||||
</SidebarContentMobile>
|
||||
}
|
||||
/>
|
||||
<DocsNavbar
|
||||
{...props}
|
||||
links={links}
|
||||
|
||||
Reference in New Issue
Block a user