mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Improve sidebar design
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
Improve sidebar design
|
||||
@@ -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, []);
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user