OpenAPI: Improve schema UI array rendering

This commit is contained in:
Fuma Nama
2026-01-17 19:15:36 +08:00
parent d60324c4f3
commit 897cc26485
4 changed files with 141 additions and 174 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"fumadocs-openapi": patch
---
Improve schema UI array rendering
+1 -1
View File
@@ -14,5 +14,5 @@
"prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "oxc.oxc-vscode",
"oxc.linter.enabled": false
"oxc.fmt.configPath": ".oxfmtrc.json"
}
+1
View File
@@ -758,6 +758,7 @@ components:
type: array
items:
type: string
enum: ['test', 'hello']
examples:
- ['solar-system', 'rocky', 'explored']
lastUpdated:
+134 -173
View File
@@ -11,7 +11,7 @@ import {
useState,
} from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
import type { SchemaData, SchemaUIGeneratedData } from '@/ui/schema';
import type { SchemaUIGeneratedData } from '@/ui/schema';
import {
Collapsible,
CollapsibleContent,
@@ -26,16 +26,8 @@ import { cva } from 'class-variance-authority';
type DataContextType = SchemaUIGeneratedData;
interface PropertyContextType {
renderRef: (options: RenderRefOptions) => ReactNode;
}
interface RenderRefOptions {
text: ReactNode;
pathName: ReactNode;
$ref: string;
inlineUnion?: boolean;
interface PopoverContextType {
renderTrigger: (props: { pathName: ReactNode; $ref: string; children: ReactNode }) => ReactNode;
}
const typeVariants = cva('text-sm text-start text-fd-muted-foreground font-mono', {
@@ -47,8 +39,8 @@ const typeVariants = cva('text-sm text-start text-fd-muted-foreground font-mono'
},
});
const PropertyContext = createContext<PropertyContextType>({
renderRef: (props) => <RootRef {...props} />,
const PopoverContext = createContext<PopoverContextType>({
renderTrigger: (props) => <RootPopoverTrigger {...props} />,
});
const DataContext = createContext<DataContextType | null>(null);
@@ -57,8 +49,8 @@ function useData() {
return use(DataContext)!;
}
function useProperty() {
return use(PropertyContext);
function usePopover() {
return use(PopoverContext);
}
export interface SchemaUIProps {
@@ -70,36 +62,43 @@ export interface SchemaUIProps {
}
export function SchemaUI({ name, required = false, as = 'property', generated }: SchemaUIProps) {
const schema = generated.refs[generated.$root];
const context: DataContextType = useMemo(() => generated, [generated]);
const isProperty = as === 'property' || !isExpandable(schema);
return (
<DataContext value={context}>
{isProperty ? (
<SchemaUIProperty
name={name}
$type={generated.$root}
overrides={{
required,
}}
/>
) : (
<SchemaUIContent $type={generated.$root} />
)}
<DataContext value={generated}>
<SchemaUIProperty
name={name}
$type={generated.$root}
overrides={{
required,
}}
variant={
as === 'property' || generated.refs[generated.$root].type === 'primitive'
? 'default'
: 'expand'
}
/>
</DataContext>
);
}
function SchemaUIContent({ $type }: { $type: string }) {
function SchemaUIProperty({
name,
$type,
variant = 'default',
overrides,
}: {
name: ReactNode;
$type: string;
variant?: 'default' | 'expand';
overrides?: Partial<PropertyProps>;
}) {
const { refs } = useData();
const schema = refs[$type];
let child: ReactNode = null;
const renderRef = useRenderRef();
let type: ReactNode = schema.typeName;
if (schema.type === 'or' && schema.items.length > 0) {
child = (
<>
{child}
if ((schema.type === 'or' || schema.type === 'and') && schema.items.length > 0) {
if (variant === 'expand')
return (
<Tabs defaultValue={schema.items[0].$type}>
<TabsList>
{schema.items.map((item) => (
@@ -115,34 +114,32 @@ function SchemaUIContent({ $type }: { $type: string }) {
forceMount={undefined}
className="py-0"
>
<SchemaUIContent {...item} />
<SchemaUIProperty {...item} variant="expand" />
</TabsContent>
))}
</Tabs>
</>
);
}
if (schema.type === 'object' && schema.props.length > 0) {
child = (
<>
{child}
{schema.props.map((prop) => (
<SchemaUIProperty
key={prop.name}
name={prop.name}
$type={prop.$type}
overrides={{ required: prop.required }}
/>
))}
</>
);
}
if (schema.type === 'array') {
child = (
<>
{child}
);
type = renderRef({
pathName: name,
$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 }}
/>
));
type = renderRef({
pathName: name,
$ref: $type,
});
} else if (schema.type === 'array') {
if (variant === 'expand')
return (
<Collapsible className="my-2">
<CollapsibleTrigger
className={cn(
@@ -154,57 +151,19 @@ function SchemaUIContent({ $type }: { $type: string }) {
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="-mt-px bg-fd-card px-3 rounded-lg rounded-tl-none border shadow-sm">
<SchemaUIContent $type={schema.item.$type} />
<SchemaUIProperty name="" $type={schema.item.$type} variant="expand" />
</CollapsibleContent>
</Collapsible>
</>
);
}
);
return child;
}
function SchemaUIProperty({
name,
$type,
overrides,
}: {
name: ReactNode;
$type: string;
overrides?: Partial<PropertyProps>;
}) {
const { renderRef } = useProperty();
const { refs } = useData();
const schema = refs[$type];
let type: ReactNode = schema.typeName;
if ((schema.type === 'or' || schema.type === 'and') && schema.items.length > 0) {
type = renderRef({
text: schema.aliasName,
pathName: name,
$ref: $type,
inlineUnion: true,
});
}
if (schema.type === 'object' && schema.props.length > 0) {
type = renderRef({
text: schema.aliasName,
pathName: name,
$ref: $type,
});
}
if (schema.type === 'array') {
type = renderRef({
text: schema.aliasName,
pathName: name,
$ref: schema.item.$type,
});
}
return (
<Property name={name} type={type} deprecated={schema.deprecated} {...overrides}>
const child = (
<>
{schema.description}
{schema.infoTags && schema.infoTags.length > 0 && (
<div className="flex flex-row gap-2 flex-wrap my-2 not-prose empty:hidden">
@@ -213,6 +172,12 @@ function SchemaUIProperty({
))}
</div>
)}
</>
);
if (variant === 'expand') return child;
return (
<Property name={name} type={type} deprecated={schema.deprecated} {...overrides}>
{child}
</Property>
);
}
@@ -230,15 +195,15 @@ function SchemaUIPopover({ initialPath }: { initialPath: { name: ReactNode; $ref
element.parentElement.scrollTop = 0;
}, [last?.$ref]);
const context: PropertyContextType = useMemo(
const context: PopoverContextType = useMemo(
() => ({
renderRef: (props) => (
<LinkRef
{...props}
onInsert={(name, $ref) => {
setPath((path) => [...path, { name, $ref }]);
}}
/>
renderTrigger: ({ $ref, pathName, children }) => (
<button
className={cn(typeVariants({ variant: 'trigger' }))}
onClick={() => setPath((path) => [...path, { name: pathName, $ref }])}
>
{children}
</button>
),
}),
[],
@@ -255,7 +220,6 @@ function SchemaUIPopover({ initialPath }: { initialPath: { name: ReactNode; $ref
isDuplicated && 'text-orange-400',
item.$ref && 'hover:underline hover:text-fd-accent-foreground',
);
const node = item.$ref ? (
<button onClick={() => setPath((path) => path.slice(0, i + 1))} className={className}>
{item.name}
@@ -272,56 +236,82 @@ function SchemaUIPopover({ initialPath }: { initialPath: { name: ReactNode; $ref
);
})}
</div>
<PropertyContext value={context}>
<PopoverContext value={context}>
<div ref={ref} className="px-2">
<SchemaUIContent $type={last.$ref!} />
<SchemaUIProperty name="" $type={last.$ref!} variant="expand" />
</div>
</PropertyContext>
</PopoverContext>
</>
);
}
function RootRef({ text, $ref, pathName, inlineUnion }: RenderRefOptions) {
function useRenderRef() {
const { refs } = useData();
const { renderTrigger } = usePopover();
return function renderRef({
pathName,
$ref,
text,
}: {
pathName: ReactNode;
$ref: string;
text?: ReactNode;
}) {
const schema = refs[$ref];
if (schema.type === 'and' || schema.type === 'or') {
const sep = schema.type === 'and' ? '&' : '|';
return (
<span className={cn(typeVariants(), 'flex flex-row gap-2 items-center flex-wrap')}>
{schema.items.map((item, i) => (
<Fragment key={item.$type}>
{i > 0 && <span>{sep}</span>}
{renderRef({ pathName, text: item.name, $ref: item.$type })}
</Fragment>
))}
</span>
);
}
if (schema.type === 'array') {
return (
<span className={cn(typeVariants(), 'flex flex-row items-center flex-wrap')}>
{'array<'}
{renderRef({ pathName: <>{pathName}[]</>, $ref: schema.item.$type })}
{'>'}
</span>
);
}
return renderTrigger({ $ref, pathName, children: text ?? schema.aliasName });
};
}
function RootPopoverTrigger({
$ref,
pathName,
children,
}: {
pathName: ReactNode;
$ref: string;
children: ReactNode;
}) {
const ref = useCallback((element: HTMLDivElement | null) => {
if (!element || element.style.getPropertyValue('--initial-height')) return;
element.style.setProperty('--initial-height', `${element.clientHeight}px`);
}, []);
const schema = refs[$ref];
if (inlineUnion && (schema.type === 'and' || schema.type === 'or')) {
const sep = schema.type === 'and' ? '&' : '|';
return (
<span className={cn(typeVariants(), 'flex flex-row gap-2 items-center flex-wrap')}>
{schema.items.map((item, i) => (
<Fragment key={item.$type}>
{i > 0 && <span>{sep}</span>}
<RootRef pathName={pathName} text={item.name} $ref={item.$type} />
</Fragment>
))}
</span>
);
}
if (!isExpandable(refs[$ref])) {
return <span className={cn(typeVariants())}>{text}</span>;
}
return (
<Popover>
<PopoverTrigger className={cn(typeVariants({ variant: 'trigger' }))}>{text}</PopoverTrigger>
<PopoverTrigger className={cn(typeVariants({ variant: 'trigger' }))}>
{children}
</PopoverTrigger>
<PopoverContent ref={ref} className="w-[600px] min-h-(--initial-height,0) max-h-[460px] p-0">
<SchemaUIPopover
initialPath={[
{
name: (
<>
{pathName}
{schema.type === 'array' && '[]'}
</>
),
name: pathName,
$ref: $ref,
},
]}
@@ -331,31 +321,6 @@ function RootRef({ text, $ref, pathName, inlineUnion }: RenderRefOptions) {
);
}
function LinkRef({
$ref,
pathName,
onInsert,
text,
}: RenderRefOptions & {
onInsert: (name: ReactNode, $ref: string) => void;
}) {
const { refs } = useData();
if (!isExpandable(refs[$ref])) {
return <span className={cn(typeVariants())}>{text}</span>;
}
return (
<button
className={cn(typeVariants({ variant: 'trigger' }))}
onClick={() => {
onInsert(pathName, $ref);
}}
>
{text}
</button>
);
}
interface PropertyProps {
name: ReactNode;
type: ReactNode;
@@ -410,7 +375,3 @@ function Property({
</div>
);
}
function isExpandable(schema: SchemaData) {
return schema.type !== 'primitive';
}