UI: Move sidebar toolbar to top on mobile view

This commit is contained in:
Fuma Nama
2025-07-30 01:20:10 +08:00
parent 6fa1442ac8
commit e9fef34141
6 changed files with 365 additions and 262 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Move sidebar toolbar to top on mobile view
+33 -27
View File
@@ -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}")}`,
}}
/>
)}
+62 -62
View File
@@ -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>
+156 -95
View File
@@ -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 };
+9 -1
View File
@@ -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({
+100 -77
View File
@@ -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}