mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Improve TOC Popover design
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'fumadocs-core': patch
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
Improve TOC Popover design
|
||||
@@ -39,7 +39,7 @@ Try scroll down and see the button.
|
||||
|
||||
export function Test() {
|
||||
return (
|
||||
<div className="p-4 bg-secondary text-secondary-foreground rounded-md">
|
||||
<div className="p-4 border text-secondary-foreground rounded-md">
|
||||
<h2>Mera maligno</h2>
|
||||
|
||||
<p>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@next/bundle-analyzer": "^14.2.5",
|
||||
"@next/eslint-plugin-next": "^14.2.5",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "20.14.10",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
@@ -53,7 +53,7 @@
|
||||
"glslify": "^7.1.1",
|
||||
"glslx": "^0.3.0",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsconfig": "workspace:*",
|
||||
"tsx": "^4.16.2",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import { createPreset, presets } from 'fumadocs-ui/tailwind-plugin';
|
||||
import animate from 'tailwindcss-animate';
|
||||
|
||||
@@ -43,38 +44,6 @@ export default {
|
||||
'repeat-gradient-to-br':
|
||||
'repeating-linear-gradient(to bottom right, var(--tw-gradient-stops))',
|
||||
},
|
||||
keyframes: {
|
||||
updown: {
|
||||
'from, to': {
|
||||
transform: 'translateY(-20px)',
|
||||
},
|
||||
'50%': {
|
||||
transform: 'translateY(20px)',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
'from, to': {
|
||||
opacity: 0.7,
|
||||
},
|
||||
'50%': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
from: {
|
||||
'stroke-dasharray': 1000,
|
||||
},
|
||||
to: {
|
||||
'stroke-dasharray': 1000,
|
||||
'stroke-dashoffset': 2000,
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
stroke: 'stroke 5s linear infinite',
|
||||
light: 'light 2s ease-in-out infinite',
|
||||
updown: 'updown 3s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [animate],
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"**/*.mts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"next.config.js"
|
||||
"next.config.js",
|
||||
"tailwind.config.js"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
+6
-5
@@ -19,18 +19,19 @@
|
||||
"prettier": "@vercel/style-guide/prettier",
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.14.1",
|
||||
"@swc/core": "^1.6.13",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-tailwindcss": "^3.17.4",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsup": "8.1.0",
|
||||
"turbo": "2.0.6",
|
||||
"tsup": "8.1.1",
|
||||
"turbo": "2.0.7",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.0.2"
|
||||
"vitest": "^2.0.3"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"engines": {
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
"import": "./dist/source/index.js",
|
||||
"types": "./dist/source/index.d.ts"
|
||||
},
|
||||
"./utils/use-on-change": {
|
||||
"import": "./dist/utils/use-on-change.js",
|
||||
"types": "./dist/utils/use-on-change.d.ts"
|
||||
},
|
||||
"./link": {
|
||||
"import": "./dist/link.js",
|
||||
"types": "./dist/link.d.ts"
|
||||
@@ -104,6 +108,9 @@
|
||||
"middleware": [
|
||||
"./dist/middleware.d.ts"
|
||||
],
|
||||
"utils/use-on-change": [
|
||||
"./dist/utils/use-on-change.d.ts"
|
||||
],
|
||||
"mdx-plugins": [
|
||||
"./dist/mdx-plugins/index.d.ts"
|
||||
],
|
||||
@@ -150,7 +157,7 @@
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.3",
|
||||
"@types/negotiator": "^0.6.3",
|
||||
"@types/node": "20.14.10",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"algoliasearch": "^4.24.0",
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import type { AnchorHTMLAttributes, ReactNode, RefObject } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { createContext, forwardRef, useContext, useMemo, useRef } from 'react';
|
||||
import scrollIntoView from 'scroll-into-view-if-needed';
|
||||
import type { TableOfContents } from '@/server/get-toc';
|
||||
import { mergeRefs } from '@/utils/merge-refs';
|
||||
import { useOnChange } from '@/utils/use-on-change';
|
||||
import { useAnchorObserver } from './utils/use-anchor-observer';
|
||||
|
||||
const ActiveAnchorContext = createContext<string | undefined>(undefined);
|
||||
@@ -80,13 +74,12 @@ export const TOCItem = forwardRef<HTMLAnchorElement, TOCItemProps>(
|
||||
const mergedRef = mergeRefs(anchorRef, ref);
|
||||
|
||||
const isActive = activeAnchor === props.href.split('#')[1];
|
||||
const onActiveRef = useRef<(active: boolean) => void>();
|
||||
|
||||
onActiveRef.current = (active) => {
|
||||
useOnChange(isActive, (v) => {
|
||||
const element = anchorRef.current;
|
||||
if (!element) return;
|
||||
|
||||
if (active && containerRef.current) {
|
||||
if (v && containerRef.current) {
|
||||
scrollIntoView(element, {
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
@@ -96,12 +89,8 @@ export const TOCItem = forwardRef<HTMLAnchorElement, TOCItemProps>(
|
||||
});
|
||||
}
|
||||
|
||||
onActiveChange?.(active);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onActiveRef.current?.(isActive);
|
||||
}, [isActive]);
|
||||
onActiveChange?.(v);
|
||||
});
|
||||
|
||||
return (
|
||||
<a ref={mergedRef} data-active={isActive} {...props}>
|
||||
|
||||
@@ -3,13 +3,14 @@ import { defineConfig } from 'tsup';
|
||||
export default defineConfig({
|
||||
external: ['@algolia/client-search', 'algoliasearch', 'unified'],
|
||||
dts: true,
|
||||
target: 'es6',
|
||||
target: 'es2022',
|
||||
format: 'esm',
|
||||
entry: [
|
||||
'src/{toc,toc-internal,link,breadcrumb,sidebar,dynamic-link}.tsx',
|
||||
'src/{server,source,mdx-plugins}/index.ts',
|
||||
'src/{search,search-algolia}/client.ts',
|
||||
'src/search/shared.ts',
|
||||
'src/utils/use-on-change.ts',
|
||||
'src/{search,search-algolia}/server.ts',
|
||||
'src/middleware.ts',
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"postcss": "^8.4.39",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/node": "20.14.10",
|
||||
"@types/node": "20.14.11",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"fast-glob": "^3.3.1",
|
||||
"tsconfig": "workspace:*"
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@types/estree": "^1.0.5",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.3",
|
||||
"@types/node": "20.14.10",
|
||||
"@types/node": "20.14.11",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"remark": "^15.0.0",
|
||||
"tsconfig": "workspace:*",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"devDependencies": {
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/node": "20.14.10",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/ws": "^8.5.11",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
@@ -81,7 +81,7 @@
|
||||
"next": "^14.2.5",
|
||||
"tsconfig": "workspace:*",
|
||||
"unified": "^11.0.5",
|
||||
"vfile": "^6.0.1"
|
||||
"vfile": "^6.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fumadocs-core": ">= 12.1.1",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"fast-glob": "^3.3.1",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-schema-to-typescript": "^14.1.0",
|
||||
@@ -60,7 +61,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "20.14.10",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/openapi-sampler": "^1.0.3",
|
||||
"@types/react": "^18.3.3",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
} from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { cn, buttonVariants, useOnChange } from 'fumadocs-ui/components/api';
|
||||
import { cn, buttonVariants } from 'fumadocs-ui/components/api';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
"fumadocs-core": "workspace:*",
|
||||
"lucide-react": "^0.408.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"react-medium-image-zoom": "^5.2.7",
|
||||
"react-medium-image-zoom": "^5.2.8",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.4.0"
|
||||
},
|
||||
@@ -127,7 +127,7 @@
|
||||
"postcss": "^8.4.39",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-lightningcss": "^1.0.0",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"tsconfig": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
export * from '@/utils/use-on-change';
|
||||
export * from '@/utils/use-copy-button';
|
||||
export * from '@/utils/cn';
|
||||
export * from '@/theme/variants';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useAlgoliaSearch,
|
||||
} from 'fumadocs-core/search-algolia/client';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { useOnChange } from '@/utils/use-on-change';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import {
|
||||
SearchDialog,
|
||||
type SharedProps,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useDocsSearch } from 'fumadocs-core/search/client';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import { useOnChange } from '@/utils/use-on-change';
|
||||
import {
|
||||
SearchDialog,
|
||||
type SharedProps,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { isActive } from '@/utils/shared';
|
||||
import { buttonVariants, itemVariants } from '@/theme/variants';
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { useOnChange } from '@/utils/use-on-change';
|
||||
|
||||
const linkItemVariants = cva(
|
||||
'-m-2 inline-flex items-center gap-1 p-2 text-muted-foreground transition-colors [&_svg]:size-4',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import Link from 'fumadocs-core/link';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useTreeContext } from '@/contexts/tree';
|
||||
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
|
||||
@@ -19,7 +20,6 @@ import { LinkItem, type LinkItemType } from '@/components/layout/link-item';
|
||||
import { LargeSearchToggle } from '@/components/layout/search-toggle';
|
||||
import { useSearchContext } from '@/contexts/search';
|
||||
import { itemVariants } from '@/theme/variants';
|
||||
import { useOnChange } from '@/utils/use-on-change';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ChevronDown, Text } from 'lucide-react';
|
||||
import { ChevronRight, Text } from 'lucide-react';
|
||||
import type { TOCItemType } from 'fumadocs-core/server';
|
||||
import * as Primitive from 'fumadocs-core/toc-internal';
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
@@ -53,20 +54,25 @@ export function Toc({ items, header, footer }: TOCProps): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
export function TocPopover({
|
||||
items,
|
||||
header,
|
||||
footer,
|
||||
...props
|
||||
}: TOCProps & ButtonHTMLAttributes<HTMLButtonElement>): ReactElement {
|
||||
export function TocPopover({ items, header, footer }: TOCProps): ReactElement {
|
||||
const { text } = useI18n();
|
||||
const active = Primitive.useActiveAnchor();
|
||||
const current = useMemo(() => {
|
||||
if (!active) return;
|
||||
return items.find((item) => item.url === `#${active}`)?.title;
|
||||
}, [items, active]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger {...props}>
|
||||
<Text className="size-4" />
|
||||
<PopoverTrigger className="inline-flex items-center gap-2 text-nowrap px-4 py-2 text-left max-md:size-full md:px-3 md:text-muted-foreground">
|
||||
<Text className="size-4 shrink-0" />
|
||||
{text.toc}
|
||||
<ChevronDown className="ms-auto size-4 text-muted-foreground" />
|
||||
{current ? (
|
||||
<>
|
||||
<ChevronRight className="-mx-1.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{current}</span>
|
||||
</>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
hideWhenDetached
|
||||
@@ -93,16 +99,19 @@ function TOCItems({
|
||||
}): React.ReactElement {
|
||||
const { text } = useI18n();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const markerRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<PosType>();
|
||||
const active = Primitive.useActiveAnchor();
|
||||
|
||||
const setPos = useCallback(([top, height]: PosType) => {
|
||||
const element = markerRef.current;
|
||||
if (!element || containerRef.current?.clientHeight === 0) return;
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!active || !container) return;
|
||||
|
||||
element.style.setProperty('top', `${top.toString()}px`);
|
||||
element.style.setProperty('height', `${height.toString()}px`);
|
||||
element.style.setProperty('display', 'block');
|
||||
}, []);
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${active}"]`,
|
||||
);
|
||||
if (!element) return;
|
||||
setPos([element.offsetTop, element.clientHeight]);
|
||||
}, [active]);
|
||||
|
||||
if (items.length === 0)
|
||||
return (
|
||||
@@ -116,8 +125,12 @@ function TOCItems({
|
||||
<ScrollViewport className="relative min-h-0 text-sm" ref={containerRef}>
|
||||
<div
|
||||
role="none"
|
||||
ref={markerRef}
|
||||
className="absolute start-0 hidden w-0.5 bg-primary transition-all"
|
||||
className="absolute start-0 w-0.5 bg-primary transition-all"
|
||||
style={{
|
||||
top: pos ? `${pos[0].toString()}px` : undefined,
|
||||
height: pos ? `${pos[1].toString()}px` : undefined,
|
||||
display: pos ? 'block' : 'hidden',
|
||||
}}
|
||||
/>
|
||||
<Primitive.ScrollProvider containerRef={containerRef}>
|
||||
<div
|
||||
@@ -127,7 +140,7 @@ function TOCItems({
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TOCItem key={item.url} item={item} setMarker={setPos} />
|
||||
<TOCItem key={item.url} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</Primitive.ScrollProvider>
|
||||
@@ -136,24 +149,10 @@ function TOCItems({
|
||||
);
|
||||
}
|
||||
|
||||
function TOCItem({
|
||||
item,
|
||||
setMarker,
|
||||
}: {
|
||||
item: TOCItemType;
|
||||
setMarker: (v: PosType) => void;
|
||||
}): React.ReactElement {
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
function TOCItem({ item }: { item: TOCItemType }): React.ReactElement {
|
||||
return (
|
||||
<Primitive.TOCItem
|
||||
ref={ref}
|
||||
href={item.url}
|
||||
onActiveChange={(active) => {
|
||||
const element = ref.current;
|
||||
if (active && element)
|
||||
setMarker([element.offsetTop, element.clientHeight]);
|
||||
}}
|
||||
className={cn(
|
||||
'py-1 transition-colors data-[active=true]:font-medium data-[active=true]:text-primary',
|
||||
item.depth <= 2 && 'ps-4',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { SidebarProvider as BaseProvider } from 'fumadocs-core/sidebar';
|
||||
import { useOnChange } from '@/utils/use-on-change';
|
||||
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
||||
|
||||
interface SidebarContext {
|
||||
open: boolean;
|
||||
|
||||
@@ -88,7 +88,6 @@ export function DocsPage({
|
||||
items={toc}
|
||||
header={tableOfContentPopover.header}
|
||||
footer={tableOfContentPopover.footer}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-left max-md:size-full md:ps-6 md:text-muted-foreground"
|
||||
/>,
|
||||
)}
|
||||
</div>
|
||||
|
||||
Generated
+699
-389
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user