OpenAPI: better schema popover UI

This commit is contained in:
Fuma Nama
2026-02-26 17:45:09 +08:00
parent b5e14bd11e
commit fcdaa9f7fd
3 changed files with 86 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
better schema popover UI
+74 -24
View File
@@ -3,22 +3,25 @@ import {
createContext,
Fragment,
type ReactNode,
Suspense,
use,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
import type { InfoTag, SchemaUIGeneratedData } from '@/ui/schema';
import type { InfoTag, SchemaDataObjectProperty, SchemaUIGeneratedData } from '@/ui/schema';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { ChevronDown } from 'lucide-react';
import { ChevronDown, FilterIcon } from 'lucide-react';
import { Badge } from '@/ui/components/method-label';
import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover';
import { cn } from '@/utils/cn';
@@ -124,15 +127,8 @@ function SchemaUIProperty({
$ref: $type,
});
} else if (schema.type === 'object' && schema.props.length > 0) {
if (variant === 'expand')
return schema.props.map((prop) => (
<SchemaUIProperty
key={prop.name}
name={prop.name}
$type={prop.$type}
overrides={{ required: prop.required }}
/>
));
if (variant === 'expand') return <SearchProps props={schema.props} />;
type = renderRef({
pathName: name,
$ref: $type,
@@ -182,6 +178,49 @@ function SchemaUIProperty({
);
}
function SearchProps({ props }: { props: SchemaDataObjectProperty[] }) {
const [search, setSearch] = useState('');
const deferredValue = useDeferredValue(search);
return (
<>
<div className="flex items-center border my-2 rounded-md bg-fd-secondary text-fd-secondary-foreground transition-colors shadow-sm focus-within:ring-2 focus-within:ring-fd-ring">
<FilterIcon className="text-fd-muted-foreground ms-2 size-3.5" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter Properties"
className="text-sm ps-2 py-2 flex-1 outline-none placeholder:text-fd-muted-foreground"
/>
</div>
<Suspense>
<SearchPropsContent search={deferredValue} props={props} />
</Suspense>
</>
);
}
function SearchPropsContent({
search,
props,
}: {
search: string;
props: SchemaDataObjectProperty[];
}) {
search = search.trim().toLowerCase();
const filtered =
search.length > 0 ? props.filter((prop) => prop.name.toLowerCase().includes(search)) : props;
return filtered.map((prop) => (
<SchemaUIProperty
key={prop.name}
name={prop.name}
$type={prop.$type}
overrides={{ required: prop.required }}
/>
));
}
function InfoTag({ tag }: { tag: InfoTag }) {
const ref = useRef<HTMLElement>(null);
const [isTruncated, setTruncated] = useState(false);
@@ -217,18 +256,27 @@ function InfoTag({ tag }: { tag: InfoTag }) {
);
}
function SchemaUIPopover({ initialPath }: { initialPath: { name: ReactNode; $ref?: string }[] }) {
interface PathItemType {
name: ReactNode;
$ref?: string;
scrollTop?: number;
}
function SchemaUIPopover({ initialPath }: { initialPath: PathItemType[] }) {
const [path, setPath] = useState(initialPath);
const ref = useRef<HTMLDivElement>(null);
const last = path.findLast((item) => item.$ref !== undefined);
useEffect(() => {
useLayoutEffect(() => {
const last = path[0];
const element = ref.current;
if (!element || !element.parentElement) return;
if (!element || !last || !element.parentElement) return;
// reset scroll
element.parentElement.scrollTop = 0;
}, [last?.$ref]);
// recover scroll
element.parentElement.scrollTop = last?.scrollTop ?? 0;
return () => {
last.scrollTop = element.parentElement!.scrollTop;
};
}, [path]);
const context: PopoverContextType = useMemo(
() => ({
@@ -244,11 +292,11 @@ function SchemaUIPopover({ initialPath }: { initialPath: { name: ReactNode; $ref
[],
);
if (!last) return;
const currentRef = path.findLast((item) => item.$ref !== undefined);
return (
<>
<div className="sticky top-0 flex flex-row flex-wrap items-center text-sm font-medium font-mono bg-fd-muted p-2">
<div className="sticky top-0 flex flex-row flex-wrap items-center text-sm font-medium font-mono bg-fd-popover px-2 py-1.5 border-b">
{path.map((item, i) => {
const isDuplicated = path.some((other, j) => j < i && other.$ref === item.$ref);
const className = cn(
@@ -272,9 +320,11 @@ function SchemaUIPopover({ initialPath }: { initialPath: { name: ReactNode; $ref
})}
</div>
<PopoverContext value={context}>
<div ref={ref} className="px-2">
<SchemaUIProperty name="" $type={last.$ref!} variant="expand" />
</div>
{currentRef?.$ref && (
<div ref={ref} className="px-2">
<SchemaUIProperty name="" $type={currentRef.$ref} variant="expand" />
</div>
)}
</PopoverContext>
</>
);
@@ -334,7 +384,7 @@ function RootPopoverTrigger({
const ref = useCallback((element: HTMLDivElement | null) => {
if (!element || element.style.getPropertyValue('--initial-height')) return;
element.style.setProperty('--initial-height', `${element.clientHeight}px`);
element.style.setProperty('--initial-height', `${element.clientHeight + 2}px`);
}, []);
return (
+7 -5
View File
@@ -21,6 +21,12 @@ export interface InfoTag {
value: string;
}
export interface SchemaDataObjectProperty {
name: string;
$type: string;
required: boolean;
}
export type SchemaData = FieldBase &
(
| {
@@ -28,11 +34,7 @@ export type SchemaData = FieldBase &
}
| {
type: 'object';
props: {
name: string;
$type: string;
required: boolean;
}[];
props: SchemaDataObjectProperty[];
}
| {
type: 'array';