Improve OpenAPI codesample (#3090)

This commit is contained in:
Nolann B.
2025-04-08 09:35:22 +02:00
committed by GitHub
parent 2d01653a78
commit cbd768a095
12 changed files with 386 additions and 111 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI codesample (add OpenAPISelect component)
@@ -90,7 +90,7 @@
/* Method Tags */
.openapi-method {
@apply rounded uppercase font-mono font-bold text-xs px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
@apply rounded uppercase font-mono shrink-0 font-bold text-xs px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
}
.openapi-method-get {
@@ -423,8 +423,20 @@
@apply flex flex-row items-center;
}
.openapi-codesample-header .openapi-select > button {
@apply border-none;
}
.openapi-codesample-header-content {
@apply flex flex-row items-center h-fit;
@apply flex flex-row items-center justify-between h-fit p-2.5;
}
.openapi-codesample-header-content .openapi-path {
@apply flex items-center font-mono *:text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
}
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
@apply text-[0.813rem];
}
.openapi-codesample-footer {
@@ -437,7 +449,7 @@
/* Path */
.openapi-path {
@apply flex items-start text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
@apply flex items-center text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -451,12 +463,12 @@
}
.openapi-path .openapi-method {
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-2;
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-1;
}
.openapi-path-title {
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
@apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors;
@apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
@apply whitespace-nowrap md:whitespace-normal;
}
@@ -468,14 +480,6 @@
display: none;
}
/* .openapi-path-copy {
@apply absolute opacity-0 h-fit right-0 top-1/2 -translate-y-1/2 bg-light dark:bg-dark border rounded-md border-tint-subtle px-1.5 py-0;
}
.openapi-path-title:hover .openapi-path-copy {
@apply opacity-11;
} */
.openapi-path-title em {
@apply not-italic text-primary font-medium;
}
@@ -502,7 +506,8 @@
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
}
.openapi-panel-footer {
.openapi-panel-footer,
.openapi-codesample-footer {
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint;
}
@@ -517,7 +522,57 @@
/* Common Elements */
.openapi-select {
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 truncate border border-tint-subtle bg-tint;
/* unstyled */
}
/* Prevent react-aria popover from setting overflow:auto on body */
body:has(.openapi-select-popover) {
overflow: unset !important;
}
.openapi-select > button {
@apply flex items-center cursor-pointer gap-1.5 text-tint-strong max-w-60 rounded text-xs leading-6 px-1.5 truncate border border-tint-subtle bg-tint;
@apply hover:bg-tint-hover transition-all;
}
.openapi-select > button > span.react-aria-SelectValue {
@apply shrink truncate;
}
.openapi-select > button > .gb-icon {
@apply shrink-0;
}
.openapi-select > button svg {
@apply size-2.5;
}
.openapi-select-popover {
@apply min-w-32 max-w-fit w-auto max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
.openapi-select-popover[data-entering] {
animation: popover-enter 0.2s ease-in-out;
}
.openapi-select-popover[data-exiting] {
animation: popover-leave 0.2s ease-in-out;
}
.openapi-select-item {
@apply text-sm cursor-pointer px-1.5 py-0.5 truncate text-tint ring-0 border-none rounded !outline-none;
@apply hover:bg-tint-hover theme-gradient:hover:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
.openapi-select-item-selected {
@apply text-primary-subtle hover:text-primary hover:bg-primary-hover;
@apply theme-muted:hover:bg-primary-active theme-gradient:hover:bg-primary-active tint:font-semibold;
@apply contrast-more:text-primary contrast-more:hover:text-primary-strong contrast-more:font-semibold;
}
.openapi-select-listbox {
@apply flex flex-col gap-1 focus:ring-0 focus:outline-none;
}
.openapi-select:focus {
@@ -580,8 +635,10 @@
@apply text-primary after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
}
.openapi-tabs-panel {
.openapi-tabs-panel,
.openapi-codesample-panel {
@apply flex-1 text-sm relative focus-visible:outline-none;
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
}
/* Disclosure group */
@@ -731,6 +788,32 @@
}
}
.openapi-copy-button {
@apply hover:brightness-95;
@keyframes popover-enter {
0% {
opacity: 0;
transform: translateY(4px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes popover-leave {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(4px) scale(0.95);
}
}
.openapi-copy-button {
@apply hover:brightness-95 cursor-pointer;
}
.openapi-copy-button[data-disabled="true"] {
@apply cursor-default;
}
@@ -4,6 +4,7 @@ import clsx from 'clsx';
import { useRef, useState } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
import { useDisclosureState } from 'react-stately';
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
import { Section, SectionBody, SectionHeader, SectionHeaderContent } from './StaticSection';
interface InteractiveSectionTab {
@@ -106,24 +107,25 @@ export function InteractiveSection(props: {
}}
>
{tabs.length > 1 ? (
<select
<OpenAPISelect
className={clsx(
'openapi-section-select',
'openapi-select',
`${className}-tabs-select`
)}
value={selectedTab?.key ?? ''}
onChange={(event) => {
setSelectedTab(event.target.value);
items={tabs}
selectedKey={selectedTab?.key ?? ''}
onSelectionChange={(key) => {
setSelectedTab(String(key));
state.expand();
}}
placement="bottom end"
>
{tabs.map((tab) => (
<option key={tab.key} value={tab.key}>
<OpenAPISelectItem key={tab.key} id={tab.key} value={tab}>
{tab.label}
</option>
</OpenAPISelectItem>
))}
</select>
</OpenAPISelect>
) : null}
</div>
</SectionHeader>
@@ -3,15 +3,14 @@ import {
OpenAPIMediaTypeExamplesBody,
OpenAPIMediaTypeExamplesSelector,
} from './OpenAPICodeSampleInteractive';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { OpenAPICodeSampleBody } from './OpenAPICodeSampleSelector';
import { ScalarApiButton } from './ScalarApiButton';
import { StaticSection } from './StaticSection';
import { type CodeSampleGenerator, codeSampleGenerators } from './code-samples';
import { generateMediaTypeExamples, generateSchemaExample } from './generateSchemaExample';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import type { OpenAPIContext, OpenAPIOperationData } from './types';
import { getDefaultServerURL } from './util/server';
import { checkIsReference, createStateKey } from './utils';
import { checkIsReference } from './utils';
const CUSTOM_CODE_SAMPLES_KEYS = ['x-custom-examples', 'x-code-samples', 'x-codeSamples'] as const;
@@ -44,13 +43,7 @@ export function OpenAPICodeSample(props: {
return null;
}
return (
<OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
<StaticSection header={<OpenAPITabsList />} className="openapi-codesample">
<OpenAPITabsPanels />
</StaticSection>
</OpenAPITabs>
);
return <OpenAPICodeSampleBody data={data} items={samples} />;
}
/**
@@ -3,7 +3,8 @@ import clsx from 'clsx';
import { useCallback } from 'react';
import { useStore } from 'zustand';
import type { MediaTypeRenderer } from './OpenAPICodeSample';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
type MediaTypeState = {
mediaType: string;
@@ -15,27 +16,27 @@ function useMediaTypeState(
defaultKey: string
): MediaTypeState {
const { method, path } = data;
const store = useStore(getOrCreateTabStoreByKey(`media-type-${method}-${path}`, defaultKey));
if (typeof store.tabKey !== 'string') {
const store = useStore(getOrCreateStoreByKey(`media-type-${method}-${path}`, defaultKey));
if (typeof store.key !== 'string') {
throw new Error('Media type key is not a string');
}
return {
mediaType: store.tabKey,
setMediaType: useCallback((index: string) => store.setTabKey(index), [store.setTabKey]),
mediaType: store.key,
setMediaType: useCallback((index: string) => store.setKey(index), [store.setKey]),
};
}
function useMediaTypeSampleIndexState(data: { method: string; path: string }, mediaType: string) {
const { method, path } = data;
const store = useStore(
getOrCreateTabStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
getOrCreateStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
);
if (typeof store.tabKey !== 'number') {
if (typeof store.key !== 'number') {
throw new Error('Example key is not a number');
}
return {
index: store.tabKey,
setIndex: useCallback((index: number) => store.setTabKey(index), [store.setTabKey]),
index: store.key,
setIndex: useCallback((index: number) => store.setKey(index), [store.setKey]),
};
}
@@ -69,18 +70,28 @@ function MediaTypeSelector(props: {
return null;
}
const items = renderers.map((renderer) => ({
key: renderer.mediaType,
label: renderer.mediaType,
}));
return (
<select
<OpenAPISelect
className={clsx('openapi-select')}
value={state.mediaType}
onChange={(e) => state.setMediaType(e.target.value)}
selectedKey={state.mediaType}
items={renderers.map((renderer) => ({
key: renderer.mediaType,
label: renderer.mediaType,
}))}
onSelectionChange={(e) => state.setMediaType(String(e))}
placement="bottom start"
>
{renderers.map((renderer) => (
<option key={renderer.mediaType} value={renderer.mediaType}>
{renderer.mediaType}
</option>
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
{item.label}
</OpenAPISelectItem>
))}
</select>
</OpenAPISelect>
);
}
@@ -95,18 +106,24 @@ function ExamplesSelector(props: {
return null;
}
const items = renderer.examples.map((example, index) => ({
key: index,
label: example.example.summary || `Example ${index + 1}`,
}));
return (
<select
className={clsx('openapi-select')}
value={String(state.index)}
onChange={(e) => state.setIndex(Number(e.target.value))}
<OpenAPISelect
items={items}
selectedKey={state.index}
onSelectionChange={(e) => state.setIndex(Number(e))}
placement="bottom start"
>
{renderer.examples.map((example, index) => (
<option key={index} value={index}>
{example.example.summary || `Example ${index + 1}`}
</option>
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
{item.label}
</OpenAPISelectItem>
))}
</select>
</OpenAPISelect>
);
}
@@ -0,0 +1,87 @@
'use client';
import { useCallback } from 'react';
import type { Key } from 'react-aria';
import { useStore } from 'zustand';
import { OpenAPIPath } from './OpenAPIPath';
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
import { StaticSection } from './StaticSection';
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
import type { OpenAPIOperationData } from './types';
function useCodeSampleState(initialKey: Key = 'default') {
const store = useStore(getOrCreateStoreByKey('codesample', initialKey));
return {
key: store.key,
setKey: useCallback((key: Key) => store.setKey(key), [store.setKey]),
};
}
type CodeSampleItem = OpenAPISelectItem & {
body: React.ReactNode;
footer?: React.ReactNode;
};
export function OpenAPICodeSampleHeader(props: {
items: CodeSampleItem[];
data: OpenAPIOperationData;
}) {
const { data, items } = props;
const state = useCodeSampleState(items[0]?.key ?? '');
const selected = items.find((item) => item.key === state.key) || items[0];
return (
<>
<OpenAPIPath canCopy={false} withServer={false} data={data} />
{items.length > 1 ? (
<OpenAPISelect
selectedKey={selected?.key}
onSelectionChange={(key) => {
state.setKey(key);
}}
items={items}
placement="bottom end"
>
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
{item.label}
</OpenAPISelectItem>
))}
</OpenAPISelect>
) : items[0] ? (
<span className="openapi-codesample-label">{items[0].label}</span>
) : null}
</>
);
}
export function OpenAPICodeSampleBody(props: {
items: CodeSampleItem[];
data: OpenAPIOperationData;
}) {
const { items, data } = props;
if (!items[0]) {
throw new Error('No items provided');
}
const state = useCodeSampleState(items[0]?.key);
const selected = items.find((item) => item.key === state.key) || items[0];
if (!selected) {
return null;
}
return (
<StaticSection
header={<OpenAPICodeSampleHeader data={data} items={items} />}
className="openapi-codesample"
>
<div id={selected.key as string} className="openapi-codesample-panel">
{selected.body ? selected.body : null}
{selected.footer ? selected.footer : null}
</div>
</StaticSection>
);
}
@@ -47,7 +47,7 @@ export function OpenAPIOperation(props: {
title: operation.summary,
})
: null}
<OpenAPIPath data={data} context={context} />
<OpenAPIPath data={data} />
</div>
<div className="openapi-columns">
<div className="openapi-column-spec">
+23 -6
View File
@@ -1,5 +1,5 @@
import { OpenAPICopyButton } from './OpenAPICopyButton';
import type { OpenAPIContext, OpenAPIOperationData } from './types';
import type { OpenAPIOperationData } from './types';
import { getDefaultServerURL } from './util/server';
/**
@@ -7,25 +7,42 @@ import { getDefaultServerURL } from './util/server';
*/
export function OpenAPIPath(props: {
data: OpenAPIOperationData;
context: OpenAPIContext;
/** Whether to show the server URL.
* @default true
*/
withServer?: boolean;
/**
* Whether the path is copyable.
* @default true
*/
canCopy?: boolean;
}) {
const { data } = props;
const { data, withServer = true, canCopy = true } = props;
const { method, path, operation } = data;
const server = getDefaultServerURL(data.servers);
const formattedPath = formatPath(path);
const element = (() => {
return (
<>
{withServer ? <span className="openapi-path-server">{server}</span> : null}
{formattedPath}
</>
);
})();
return (
<div className="openapi-path">
<div className={`openapi-method openapi-method-${method}`}>{method}</div>
<OpenAPICopyButton
value={server + path}
value={`${withServer ? server : ''}${path}`}
className="openapi-path-title"
data-deprecated={operation.deprecated}
isDisabled={!canCopy}
>
<span className="openapi-path-server">{server}</span>
{formattedPath}
{element}
</OpenAPICopyButton>
</div>
);
@@ -0,0 +1,70 @@
'use client';
import clsx from 'clsx';
import {
Button,
type Key,
ListBox,
ListBoxItem,
type ListBoxItemProps,
Popover,
type PopoverProps,
Select,
type SelectProps,
SelectValue,
} from 'react-aria-components';
export type OpenAPISelectItem = {
key: Key;
label: string;
};
interface OpenAPISelectProps<T extends OpenAPISelectItem> extends Omit<SelectProps<T>, 'children'> {
items: T[];
children: React.ReactNode | ((item: T) => React.ReactNode);
selectedKey?: Key;
onChange?: (key: string | number) => void;
placement?: PopoverProps['placement'];
}
export function OpenAPISelect<T extends OpenAPISelectItem>(props: OpenAPISelectProps<T>) {
const { items, children, className, placement } = props;
return (
<Select {...props} className={clsx('openapi-select', className)}>
<Button>
<SelectValue />
<span aria-hidden="true">
<svg
className="gb-icon"
style={{
maskImage:
"url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/chevron-down.svg?v=2&token=a463935e93')",
maskRepeat: 'no-repeat',
maskPosition: 'center center',
}}
/>
</span>
</Button>
<Popover placement={placement} className="openapi-select-popover">
<ListBox className="openapi-select-listbox" items={items}>
{children}
</ListBox>
</Popover>
</Select>
);
}
export function OpenAPISelectItem(props: ListBoxItemProps) {
return (
<ListBoxItem
{...props}
className={({ isFocused, isSelected }) =>
clsx('openapi-select-item', {
'openapi-select-item-focused': isFocused,
'openapi-select-item-selected': isSelected,
})
}
/>
);
}
+7 -7
View File
@@ -3,7 +3,7 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components';
import { useEventCallback } from 'usehooks-ts';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
export type TabItem = {
key: Key;
@@ -36,8 +36,8 @@ export function OpenAPITabs(
const { children, items, stateKey } = props;
const [tabKey, setTabKey] = useState<Key | null>(() => {
if (stateKey && typeof window !== 'undefined') {
const store = getOrCreateTabStoreByKey(stateKey);
const tabKey = store.getState().tabKey;
const store = getOrCreateStoreByKey(stateKey);
const tabKey = store.getState().key;
if (tabKey) {
return tabKey;
}
@@ -60,10 +60,10 @@ export function OpenAPITabs(
if (!stateKey) {
return undefined;
}
const store = getOrCreateTabStoreByKey(stateKey);
const store = getOrCreateStoreByKey(stateKey);
return store.subscribe((state) => {
cancelDeferRef.current?.();
cancelDeferRef.current = defer(() => selectTab(state.tabKey));
cancelDeferRef.current = defer(() => selectTab(state.key));
});
}, [stateKey, selectTab]);
useEffect(() => {
@@ -77,8 +77,8 @@ export function OpenAPITabs(
onSelectionChange={(tabKey) => {
selectTab(tabKey);
if (stateKey) {
const store = getOrCreateTabStoreByKey(stateKey);
store.setState({ tabKey });
const store = getOrCreateStoreByKey(stateKey);
store.setState({ key: tabKey });
}
}}
selectedKey={tabKey}
@@ -0,0 +1,35 @@
'use client';
import { createStore } from 'zustand';
type Key = string | number;
type State = {
key: Key | null;
};
type Actions = { setKey: (key: Key | null) => void };
type Store = State & Actions;
const createStateStore = (initial?: Key) => {
return createStore<Store>()((set) => ({
key: initial ?? null,
setKey: (key) => {
set(() => ({ key }));
},
}));
};
const defaultStores = new Map<string, ReturnType<typeof createStateStore>>();
const createStateStoreFactory = (stores: typeof defaultStores) => {
return (storeKey: string, initialKey?: Key) => {
if (!stores.has(storeKey)) {
stores.set(storeKey, createStateStore(initialKey));
}
return stores.get(storeKey)!;
};
};
export const getOrCreateStoreByKey = createStateStoreFactory(defaultStores);
@@ -1,35 +0,0 @@
'use client';
import { createStore } from 'zustand';
type Key = string | number;
type TabState = {
tabKey: Key | null;
};
type TabActions = { setTabKey: (tab: Key | null) => void };
type TabStore = TabState & TabActions;
const createTabStore = (initialTab?: Key) => {
return createStore<TabStore>()((set) => ({
tabKey: initialTab ?? null,
setTabKey: (tabKey) => {
set(() => ({ tabKey }));
},
}));
};
const defaultTabStores = new Map<string, ReturnType<typeof createTabStore>>();
const createTabStoreFactory = (stores: typeof defaultTabStores) => {
return (storeKey: string, initialKey?: Key) => {
if (!stores.has(storeKey)) {
stores.set(storeKey, createTabStore(initialKey));
}
return stores.get(storeKey)!;
};
};
export const getOrCreateTabStoreByKey = createTabStoreFactory(defaultTabStores);