UI: Improve TOC Popover design

This commit is contained in:
fuma-nama
2024-07-17 20:31:05 +08:00
parent 2ffd5ea287
commit fccdfdb175
26 changed files with 786 additions and 503 deletions
+6
View File
@@ -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>
+2 -2
View File
@@ -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 -32
View File
@@ -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],
+2 -1
View File
@@ -11,7 +11,8 @@
"**/*.mts",
"**/*.tsx",
".next/types/**/*.ts",
"next.config.js"
"next.config.js",
"tailwind.config.js"
],
"exclude": ["node_modules"]
}
+6 -5
View File
@@ -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": {
+8 -1
View File
@@ -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",
+6 -17
View File
@@ -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}>
+2 -1
View File
@@ -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',
],
+1 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -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:*"
+1 -1
View File
@@ -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:*",
+2 -2
View File
@@ -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",
+2 -1
View File
@@ -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:*",
+2 -1
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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,
+37 -38
View File
@@ -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',
+1 -1
View File
@@ -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;
-1
View File
@@ -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>
+699 -389
View File
File diff suppressed because it is too large Load Diff