OpenAPI: implement collapsible info tag (#2950)

* fix: break long words in openapi examples

* OpenAPI: implement collapsible info tag

---------

Co-authored-by: Andri <[email protected]>
This commit is contained in:
Fuma Nama
2026-01-29 21:55:02 +08:00
committed by GitHub
co-authored by Andri
parent 65ff886132
commit eb3d763d89
4 changed files with 65 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Break long words in openapi examples
+38 -3
View File
@@ -11,7 +11,7 @@ import {
useState,
} from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
import type { SchemaUIGeneratedData } from '@/ui/schema';
import type { InfoTag, SchemaUIGeneratedData } from '@/ui/schema';
import {
Collapsible,
CollapsibleContent,
@@ -167,8 +167,8 @@ function SchemaUIProperty({
{schema.description}
{schema.infoTags && schema.infoTags.length > 0 && (
<div className="flex flex-row gap-2 flex-wrap my-2 not-prose empty:hidden">
{schema.infoTags.map((tag, i) => (
<Fragment key={i}>{tag}</Fragment>
{schema.infoTags.map((tag) => (
<InfoTag key={tag.label} tag={tag} />
))}
</div>
)}
@@ -182,6 +182,41 @@ function SchemaUIProperty({
);
}
function InfoTag({ tag }: { tag: InfoTag }) {
const ref = useRef<HTMLElement>(null);
const [isTruncated, setTruncated] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
// assume the tag width will never change
setTruncated(element.scrollWidth !== element.offsetWidth);
}, []);
return (
<div className="flex flex-row items-start gap-2 bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md max-w-full">
<span className="font-medium">{tag.label}</span>
<code
ref={ref}
className={cn(
'min-w-0 flex-1 text-fd-muted-foreground',
open ? 'wrap-break-word' : 'truncate',
)}
>
{tag.value}
</code>
{isTruncated && (
<button
className={cn(buttonVariants({ size: 'icon-xs', variant: 'ghost' }))}
onClick={() => setOpen((prev) => !prev)}
>
<ChevronDown />
</button>
)}
</div>
);
}
function SchemaUIPopover({ initialPath }: { initialPath: { name: ReactNode; $ref?: string }[] }) {
const [path, setPath] = useState(initialPath);
const ref = useRef<HTMLDivElement>(null);
+20 -21
View File
@@ -8,7 +8,7 @@ import { SchemaUILazy } from '@/ui/schema/lazy';
export interface FieldBase {
description?: ReactNode;
infoTags?: ReactNode[];
infoTags?: InfoTag[];
typeName: string;
aliasName: string;
@@ -16,6 +16,11 @@ export interface FieldBase {
deprecated?: boolean;
}
export interface InfoTag {
label: string;
value: string;
}
export type SchemaData = FieldBase &
(
| {
@@ -91,31 +96,22 @@ export function generateSchemaUI(
const { showExample = false } = ctx.schemaUI ?? {};
function generateInfoTags(schema: Exclude<ResolvedSchema, boolean>) {
const fields: ReactNode[] = [];
function field(key: string, value: ReactNode) {
return (
<div className="bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md">
<span className="font-medium me-2">{key}</span>
<code className="text-fd-muted-foreground">{value}</code>
</div>
);
}
const fields: InfoTag[] = [];
if (schema.default !== undefined) {
fields.push(field('Default', JSON.stringify(schema.default)));
fields.push({ label: 'Default', value: JSON.stringify(schema.default) });
}
if (schema.pattern) {
fields.push(field('Match', schema.pattern));
fields.push({ label: 'Match', value: schema.pattern });
}
if (schema.format) {
fields.push(field('Format', schema.format));
fields.push({ label: 'Format', value: schema.format });
}
if (schema.multipleOf) {
fields.push(field('Multiple Of', schema.multipleOf));
fields.push({ label: 'Multiple Of', value: schema.multipleOf.toString() });
}
let range = formatRange(
@@ -125,10 +121,10 @@ export function generateSchemaUI(
schema.maximum,
schema.exclusiveMaximum,
);
if (range) fields.push(field('Range', range));
if (range) fields.push({ label: 'Range', value: range });
range = formatRange('length', schema.minLength, undefined, schema.maxLength, undefined);
if (range) fields.push(field('Length', range));
if (range) fields.push({ label: 'Length', value: range });
range = formatRange(
'properties',
@@ -137,18 +133,21 @@ export function generateSchemaUI(
schema.maxProperties,
undefined,
);
if (range) fields.push(field('Properties', range));
if (range) fields.push({ label: 'Properties', value: range });
range = formatRange('items', schema.minItems, undefined, schema.maxItems, undefined);
if (range) fields.push(field('Items', range));
if (range) fields.push({ label: 'Items', value: range });
if (schema.enum) {
fields.push(field('Value in', schema.enum.map((value) => JSON.stringify(value)).join(' | ')));
fields.push({
label: 'Value in',
value: schema.enum.map((value) => JSON.stringify(value)).join(' | '),
});
}
if (showExample && schema.examples) {
for (const example of schema.examples) {
fields.push(field('Example', JSON.stringify(example, null, 2)));
fields.push({ label: 'Example', value: JSON.stringify(example, null, 2) });
}
}
+2 -2
View File
@@ -131,7 +131,7 @@ test('test', async () => {
",
"infoTags": [
<div
className="bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md"
className="bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md max-w-full"
>
<span
className="font-medium me-2"
@@ -139,7 +139,7 @@ test('test', async () => {
Value in
</span>
<code
className="text-fd-muted-foreground"
className="text-fd-muted-foreground wrap-break-word"
>
"NOT_FOUND" | "FORBIDDEN" | "USAGE_EXCEEDED" | "RATE_LIMITED" | "UNAUTHORIZED" | "DISABLED" | "INSUFFICIENT_PERMISSIONS"
</code>