UI: Improve sidebar design

This commit is contained in:
Fuma Nama
2025-02-14 18:50:21 +08:00
parent 2d3178958d
commit f782c2cdad
21 changed files with 88 additions and 81 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Improve sidebar design
+4
View File
@@ -56,6 +56,10 @@
"import": "./dist/utils/use-shiki.js",
"types": "./dist/utils/use-shiki.d.ts"
},
"./utils/use-effect-event": {
"import": "./dist/utils/use-effect-event.js",
"types": "./dist/utils/use-effect-event.d.ts"
},
"./link": {
"import": "./dist/link.js",
"types": "./dist/link.d.ts"
@@ -0,0 +1,16 @@
'use client';
import { useCallback, useRef } from 'react';
/**
* Don't use this, could be deleted anytime.
*
* @internal
*/
export function useEffectEvent<F extends (...params: never[]) => unknown>(
callback: F,
): F {
const ref = useRef(callback);
ref.current = callback;
return useCallback(((...params) => ref.current(...params)) as F, []);
}
+1
View File
@@ -21,6 +21,7 @@ export default defineConfig({
'src/search/orama-cloud.ts',
'src/utils/use-on-change.ts',
'src/utils/use-shiki.tsx',
'src/utils/use-effect-event.ts',
'src/i18n/index.ts',
],
});
+1 -1
View File
@@ -104,7 +104,7 @@ export function APIExample(props: HTMLAttributes<HTMLDivElement>) {
<div
{...props}
className={cn(
'prose-no-margin md:sticky md:top-[var(--fd-api-info-top)] xl:w-[400px]',
'prose-no-margin md:sticky md:top-(--fd-api-info-top) xl:w-[400px]',
props.className,
)}
>
+5 -7
View File
@@ -1,6 +1,6 @@
'use client';
import { type HTMLAttributes, useCallback, useEffect, useState } from 'react';
import { type HTMLAttributes, useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
@@ -36,11 +36,6 @@ export function Banner({
if (globalKey) setOpen(localStorage.getItem(globalKey) !== 'true');
}, [globalKey]);
const onClick = useCallback(() => {
setOpen(false);
if (globalKey) localStorage.setItem(globalKey, 'true');
}, [globalKey]);
if (!open) return null;
return (
@@ -81,7 +76,10 @@ export function Banner({
<button
type="button"
aria-label="Close Banner"
onClick={onClick}
onClick={() => {
setOpen(false);
if (globalKey) localStorage.setItem(globalKey, 'true');
}}
className={cn(
buttonVariants({
color: 'ghost',
+1 -2
View File
@@ -91,8 +91,7 @@ export const CodeBlock = forwardRef<HTMLElement, CodeBlockProps>(
{...props}
className={cn(
'not-prose group fd-codeblock relative my-6 overflow-hidden rounded-lg border bg-fd-secondary/50 text-sm',
keepBackground &&
'bg-[var(--shiki-light-bg)] dark:bg-[var(--shiki-dark-bg)]',
keepBackground && 'bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)',
props.className,
)}
>
+5 -10
View File
@@ -7,7 +7,6 @@ import {
type ReactNode,
useEffect,
useState,
useRef,
type ButtonHTMLAttributes,
useCallback,
type HTMLAttributes,
@@ -24,6 +23,7 @@ import {
} from '@radix-ui/react-dialog';
import type { SortedResult } from 'fumadocs-core/server';
import { cva } from 'class-variance-authority';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
export type SearchLink = [name: string, href: string];
@@ -153,7 +153,7 @@ function SearchResults({
sidebar.setOpen(false);
};
function onKey(e: KeyboardEvent) {
const onKey = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key == 'ArrowUp') {
setActive((cur) => {
const idx = items.findIndex((item) => item.id === cur);
@@ -173,17 +173,12 @@ function SearchResults({
if (selected) onOpen(selected);
e.preventDefault();
}
}
const listenerRef = useRef(onKey);
listenerRef.current = onKey;
});
useEffect(() => {
const listener = (e: KeyboardEvent) => listenerRef.current?.(e);
window.addEventListener('keydown', listener);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', listener);
window.removeEventListener('keydown', onKey);
};
}, []);
@@ -69,7 +69,7 @@ export function RootToggle({
<ChevronsUpDown className="size-4 text-fd-muted-foreground" />
</PopoverTrigger>
) : null}
<PopoverContent className="w-[var(--radix-popover-trigger-width)] overflow-hidden p-0">
<PopoverContent className="w-(--radix-popover-trigger-width) overflow-hidden p-0">
{options.map((item) => (
<Link
key={item.url}
@@ -7,6 +7,7 @@ import {
} from 'react';
import * as Primitive from 'fumadocs-core/toc';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
export type TOCThumb = [top: number, height: number];
@@ -45,22 +46,20 @@ export function TocThumb({
...props
}: HTMLAttributes<HTMLDivElement> & {
containerRef: RefObject<HTMLElement | null>;
}): ReactNode {
}) {
const active = Primitive.useActiveAnchors();
const thumbRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(active);
activeRef.current = active;
const onResize = useEffectEvent(() => {
if (!containerRef.current || !thumbRef.current) return;
update(thumbRef.current, calc(containerRef.current, active));
});
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const onResize = (): void => {
if (!thumbRef.current) return;
update(thumbRef.current, calc(container, activeRef.current));
};
onResize();
const observer = new ResizeObserver(onResize);
observer.observe(container);
@@ -68,7 +67,7 @@ export function TocThumb({
return () => {
observer.disconnect();
};
}, [containerRef]);
}, [containerRef, onResize]);
useOnChange(active, () => {
if (!containerRef.current || !thumbRef.current) return;
+3 -3
View File
@@ -48,7 +48,7 @@ export function Toc(props: HTMLAttributes<HTMLDivElement>) {
id="nd-toc"
{...props}
className={cn(
'sticky top-[calc(var(--fd-banner-height)+var(--fd-nav-height))] h-[var(--fd-toc-height)] pb-2 pt-12',
'sticky top-[calc(var(--fd-banner-height)+var(--fd-nav-height))] h-(--fd-toc-height) pb-2 pt-12',
toc,
props.className,
)}
@@ -188,12 +188,12 @@ export function TocPopoverTrigger({
className={cn(
'size-4 shrink-0 text-fd-muted-foreground transition-all',
!current && 'opacity-0',
open ? 'rotate-90' : '-mx-1.5',
open ? 'rotate-90' : '-ms-1.5',
)}
/>
<span
className={cn(
'truncate text-fd-muted-foreground transition-opacity',
'truncate text-fd-muted-foreground transition-opacity -ms-1.5',
(!current || open) && 'opacity-0',
)}
>
+4 -9
View File
@@ -9,13 +9,13 @@ import {
useState,
createContext,
useContext,
useRef,
useLayoutEffect,
useId,
useEffect,
} from 'react';
import { cn } from '@/utils/cn';
import * as Primitive from './ui/tabs';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
export { Primitive };
@@ -80,17 +80,12 @@ export function Tabs({
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-reconstruct the collection if items changed
const collection = useMemo(() => createCollection(), [items]);
const onChange: ChangeListener = (v) => {
const onUpdate: ChangeListener = useEffectEvent((v) => {
if (values.includes(v)) setValue(v);
};
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
});
useLayoutEffect(() => {
if (!groupId) return;
const onUpdate: ChangeListener = (v) => onChangeRef.current(v);
const previous = persist
? localStorage.getItem(groupId)
: sessionStorage.getItem(groupId);
@@ -100,7 +95,7 @@ export function Tabs({
return () => {
removeChangeListener(groupId, onUpdate);
};
}, [groupId, persist]);
}, [groupId, onUpdate, persist]);
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
@@ -61,7 +61,7 @@ const NavigationMenuViewport = React.forwardRef<
<Primitive.Viewport
{...props}
className={cn(
'relative h-[var(--radix-navigation-menu-viewport-height)] w-full origin-[top_center] overflow-hidden text-fd-popover-foreground transition-[width,height] duration-300 data-[state=closed]:animate-fd-nav-menu-out data-[state=open]:animate-fd-nav-menu-in',
'relative h-(--radix-navigation-menu-viewport-height) w-full origin-[top_center] overflow-hidden text-fd-popover-foreground transition-[width,height] duration-300 data-[state=closed]:animate-fd-nav-menu-out data-[state=open]:animate-fd-nav-menu-in',
className,
)}
/>
+4 -8
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, type ReactNode, useRef } from 'react';
import { type ReactNode } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import {
useI18n,
@@ -9,6 +9,7 @@ import {
defaultTranslations,
type LocaleItem,
} from './contexts/i18n';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
interface I18nProviderProps {
/**
@@ -43,7 +44,7 @@ export function I18nProvider({
const router = useRouter();
const pathname = usePathname();
const onChangeCallback = (value: string) => {
const onChange = useEffectEvent((value: string) => {
const segments = pathname.split('/').filter((v) => v.length > 0);
// If locale prefix hidden
@@ -55,12 +56,7 @@ export function I18nProvider({
router.push(`/${segments.join('/')}`);
router.refresh();
};
const onChangeRef = useRef(onChangeCallback);
onChangeRef.current = onChangeCallback;
const onChange = useCallback((v: string) => onChangeRef.current(v), []);
});
return (
<I18nContext.Provider
+1 -1
View File
@@ -28,7 +28,7 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
id="nd-subnav"
{...props}
className={cn(
'sticky top-[var(--fd-banner-height)] z-30 flex h-14 flex-row items-center border-b border-fd-foreground/10 px-4 backdrop-blur-lg transition-colors',
'sticky top-(--fd-banner-height) z-30 flex h-14 flex-row items-center border-b border-fd-foreground/10 px-4 backdrop-blur-lg transition-colors',
(!isTransparent || open) && 'bg-fd-background/80',
props.className,
)}
+8 -12
View File
@@ -1,7 +1,7 @@
import type { PageTree } from 'fumadocs-core/server';
import { type ReactNode, type HTMLAttributes } from 'react';
import Link from 'next/link';
import { Languages } from 'lucide-react';
import { ChevronRight, Languages } from 'lucide-react';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import {
@@ -103,7 +103,7 @@ export function DocsLayout({
id="nd-docs-layout"
{...props.containerProps}
className={cn(
'flex flex-1 flex-row pe-[var(--fd-layout-offset)]',
'flex flex-1 flex-row pe-(--fd-layout-offset)',
variables,
props.containerProps?.className,
)}
@@ -119,21 +119,17 @@ export function DocsLayout({
color: 'secondary',
size: 'icon',
}),
'fixed bottom-3 z-30 data-[collapsed=false]:hidden max-md:hidden',
'fixed top-1/2 -translate-y-1/2 start-0 z-40 text-fd-muted-foreground border-s-0 rounded-s-none shadow-md data-[collapsed=false]:hidden max-md:hidden',
)}
style={{
insetInlineStart: 'calc(var(--fd-layout-offset) + 0.5rem)',
}}
/>
>
<ChevronRight />
</SidebarCollapseTrigger>
) : null}
{replaceOrDefault(
{ enabled: sidebarEnabled, component: sidebarReplace },
<Aside
{...sidebar}
className={cn(
'md:ps-[var(--fd-layout-offset)]',
sidebar.className,
)}
className={cn('md:ps-(--fd-layout-offset)', sidebar.className)}
>
<SidebarHeader>
<SidebarHeaderItems
@@ -209,7 +205,7 @@ function SidebarHeaderItems({
color: 'ghost',
size: 'icon',
}),
'ms-auto text-fd-muted-foreground max-md:hidden',
'ms-auto text-fd-muted-foreground [&_svg]:size-4.5 max-md:hidden',
)}
/>
)}
+2 -2
View File
@@ -118,7 +118,7 @@ export function CollapsibleSidebar(props: SidebarProps) {
className={cn(
'md:transition-all',
collapsed &&
'md:-me-[var(--fd-sidebar-width)] md:translate-x-[calc(var(--fd-sidebar-offset)*-1)] rtl:md:translate-x-[var(--fd-sidebar-offset)]',
'md:-me-(--fd-sidebar-width) md:translate-x-[calc(var(--fd-sidebar-offset)*-1)] rtl:md:translate-x-(--fd-sidebar-offset)',
collapsed && hover && 'z-50 md:translate-x-0',
collapsed && !hover && 'md:opacity-0',
props.className,
@@ -153,7 +153,7 @@ export function Sidebar({
blockScrollingWidth={768} // md
{...props}
className={cn(
'fixed top-[calc(var(--fd-banner-height)+var(--fd-nav-height))] z-30 bg-fd-card text-sm md:sticky md:h-[var(--fd-sidebar-height)]',
'fixed top-[calc(var(--fd-banner-height)+var(--fd-nav-height))] z-30 bg-fd-card text-sm md:sticky md:h-(--fd-sidebar-height)',
'max-md:inset-x-0 max-md:bottom-0 max-md:bg-fd-background/80 max-md:text-[15px] max-md:backdrop-blur-lg max-md:data-[open=false]:invisible',
props.className,
)}
+1 -1
View File
@@ -34,7 +34,7 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
id="nd-nav"
{...props}
className={cn(
'fixed left-1/2 top-[var(--fd-banner-height)] z-40 box-content w-full max-w-fd-container -translate-x-1/2 border-b border-fd-foreground/10 transition-colors lg:mt-2 lg:w-[calc(100%-1rem)] lg:rounded-2xl lg:border',
'fixed left-1/2 top-(--fd-banner-height) z-40 box-content w-full max-w-fd-container -translate-x-1/2 border-b border-fd-foreground/10 transition-colors lg:mt-2 lg:w-[calc(100%-1rem)] lg:rounded-2xl lg:border',
value.length > 0 ? 'shadow-lg' : 'shadow-sm',
(!isTransparent || value.length > 0) &&
'bg-fd-background/80 backdrop-blur-lg',
+1 -1
View File
@@ -16,7 +16,7 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
id="nd-subnav"
{...props}
className={cn(
'fixed inset-x-0 top-(--fd-banner-height) z-10 h-14 pe-[var(--fd-layout-offset)] backdrop-blur-lg transition-colors',
'fixed inset-x-0 top-(--fd-banner-height) z-10 h-14 pe-(--fd-layout-offset) backdrop-blur-lg transition-colors',
(!isTransparent || open) && 'bg-fd-background/80',
props.className,
)}
+1 -1
View File
@@ -86,7 +86,7 @@ export function DocsLayout({
id="nd-docs-layout"
{...props.containerProps}
className={cn(
'flex w-full flex-1 flex-row pe-[var(--fd-layout-offset)]',
'flex w-full flex-1 flex-row pe-(--fd-layout-offset)',
variables,
props.containerProps?.className,
)}
+15 -12
View File
@@ -25,6 +25,7 @@ import {
import { usePageStyles } from '@/contexts/layout';
import { isActive } from '@/utils/is-active';
import { TocPopover } from '@/components/layout/toc';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
export function TocPopoverHeader(props: HTMLAttributes<HTMLDivElement>) {
const ref = useRef<HTMLElement>(null);
@@ -33,24 +34,28 @@ export function TocPopoverHeader(props: HTMLAttributes<HTMLDivElement>) {
const { tocNav } = usePageStyles();
const { isTransparent } = useNav();
useEffect(() => {
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
const onClick = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as HTMLElement))
setOpen(false);
};
if (ref.current && !ref.current.contains(e.target as HTMLElement))
setOpen(false);
});
window.addEventListener('pointerdown', onClick);
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('pointerdown', onClick);
window.removeEventListener('click', onClick);
};
}, [open, setOpen]);
}, [onClick]);
return (
<div
className="sticky overflow-visible z-10 h-8"
className={cn(
'sticky overflow-visible z-10 h-8',
tocNav,
props.className,
)}
style={{
top: 'calc(var(--fd-banner-height) + var(--fd-nav-height))',
}}
@@ -65,8 +70,6 @@ export function TocPopoverHeader(props: HTMLAttributes<HTMLDivElement>) {
(!isTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
sidebar.open && 'opacity-0',
tocNav,
props.className,
)}
>
{props.children}
@@ -227,7 +230,7 @@ export function Breadcrumb(options: BreadcrumbProps) {
if (items.length === 0) return null;
return (
<div className="flex flex-row items-center gap-1.5 text-sm text-fd-muted-foreground -mb-1.5">
<div className="flex flex-row items-center gap-1.5 text-[15px] text-fd-muted-foreground -mb-1.5">
{items.map((item, i) => {
const className = cn(
'truncate',