From cbd768a0955d94a8fa027c69ea8148c61498d840 Mon Sep 17 00:00:00 2001
From: "Nolann B." <100787331+nolannbiron@users.noreply.github.com>
Date: Tue, 8 Apr 2025 09:35:22 +0200
Subject: [PATCH] Improve OpenAPI codesample (#3090)
---
.changeset/pink-students-grow.md | 6 +
.../components/DocumentView/OpenAPI/style.css | 119 +++++++++++++++---
.../react-openapi/src/InteractiveSection.tsx | 18 +--
.../react-openapi/src/OpenAPICodeSample.tsx | 13 +-
.../src/OpenAPICodeSampleInteractive.tsx | 69 ++++++----
.../src/OpenAPICodeSampleSelector.tsx | 87 +++++++++++++
.../react-openapi/src/OpenAPIOperation.tsx | 2 +-
packages/react-openapi/src/OpenAPIPath.tsx | 29 ++++-
packages/react-openapi/src/OpenAPISelect.tsx | 70 +++++++++++
packages/react-openapi/src/OpenAPITabs.tsx | 14 +--
.../src/getOrCreateStoreByKey.ts | 35 ++++++
.../src/useSyncedTabsGlobalState.ts | 35 ------
12 files changed, 386 insertions(+), 111 deletions(-)
create mode 100644 .changeset/pink-students-grow.md
create mode 100644 packages/react-openapi/src/OpenAPICodeSampleSelector.tsx
create mode 100644 packages/react-openapi/src/OpenAPISelect.tsx
create mode 100644 packages/react-openapi/src/getOrCreateStoreByKey.ts
delete mode 100644 packages/react-openapi/src/useSyncedTabsGlobalState.ts
diff --git a/.changeset/pink-students-grow.md b/.changeset/pink-students-grow.md
new file mode 100644
index 000000000..b15a3f18b
--- /dev/null
+++ b/.changeset/pink-students-grow.md
@@ -0,0 +1,6 @@
+---
+'@gitbook/react-openapi': patch
+'gitbook': patch
+---
+
+Improve OpenAPI codesample (add OpenAPISelect component)
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
index ad32f7e45..e84e43718 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
@@ -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;
}
diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx
index 255ff53f0..8ae60649c 100644
--- a/packages/react-openapi/src/InteractiveSection.tsx
+++ b/packages/react-openapi/src/InteractiveSection.tsx
@@ -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 ? (
-
+
) : null}
diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx
index 5e36d50bd..9a99f970b 100644
--- a/packages/react-openapi/src/OpenAPICodeSample.tsx
+++ b/packages/react-openapi/src/OpenAPICodeSample.tsx
@@ -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 (
-
- } className="openapi-codesample">
-
-
-
- );
+ return ;
}
/**
diff --git a/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx
index 17cf16a62..6476cc42c 100644
--- a/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx
+++ b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx
@@ -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 (
-
+
);
}
@@ -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 (
-
+
);
}
diff --git a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx
new file mode 100644
index 000000000..f3f18ffc9
--- /dev/null
+++ b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx
@@ -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 (
+ <>
+
+ {items.length > 1 ? (
+ {
+ state.setKey(key);
+ }}
+ items={items}
+ placement="bottom end"
+ >
+ {items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+ ) : items[0] ? (
+ {items[0].label}
+ ) : 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 (
+ }
+ className="openapi-codesample"
+ >
+
+ {selected.body ? selected.body : null}
+ {selected.footer ? selected.footer : null}
+
+
+ );
+}
diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx
index 3d7a63eb6..f58375b1c 100644
--- a/packages/react-openapi/src/OpenAPIOperation.tsx
+++ b/packages/react-openapi/src/OpenAPIOperation.tsx
@@ -47,7 +47,7 @@ export function OpenAPIOperation(props: {
title: operation.summary,
})
: null}
-
+
diff --git a/packages/react-openapi/src/OpenAPIPath.tsx b/packages/react-openapi/src/OpenAPIPath.tsx
index ddc6893fe..aa5857a5d 100644
--- a/packages/react-openapi/src/OpenAPIPath.tsx
+++ b/packages/react-openapi/src/OpenAPIPath.tsx
@@ -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 ?
{server} : null}
+ {formattedPath}
+ >
+ );
+ })();
+
return (
{method}
- {server}
- {formattedPath}
+ {element}
);
diff --git a/packages/react-openapi/src/OpenAPISelect.tsx b/packages/react-openapi/src/OpenAPISelect.tsx
new file mode 100644
index 000000000..14b83b77d
--- /dev/null
+++ b/packages/react-openapi/src/OpenAPISelect.tsx
@@ -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
extends Omit, 'children'> {
+ items: T[];
+ children: React.ReactNode | ((item: T) => React.ReactNode);
+ selectedKey?: Key;
+ onChange?: (key: string | number) => void;
+ placement?: PopoverProps['placement'];
+}
+
+export function OpenAPISelect(props: OpenAPISelectProps) {
+ const { items, children, className, placement } = props;
+
+ return (
+
+ );
+}
+
+export function OpenAPISelectItem(props: ListBoxItemProps) {
+ return (
+
+ clsx('openapi-select-item', {
+ 'openapi-select-item-focused': isFocused,
+ 'openapi-select-item-selected': isSelected,
+ })
+ }
+ />
+ );
+}
diff --git a/packages/react-openapi/src/OpenAPITabs.tsx b/packages/react-openapi/src/OpenAPITabs.tsx
index 6f8eec0c3..f3502ba1d 100644
--- a/packages/react-openapi/src/OpenAPITabs.tsx
+++ b/packages/react-openapi/src/OpenAPITabs.tsx
@@ -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(() => {
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}
diff --git a/packages/react-openapi/src/getOrCreateStoreByKey.ts b/packages/react-openapi/src/getOrCreateStoreByKey.ts
new file mode 100644
index 000000000..06f4cc44b
--- /dev/null
+++ b/packages/react-openapi/src/getOrCreateStoreByKey.ts
@@ -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()((set) => ({
+ key: initial ?? null,
+ setKey: (key) => {
+ set(() => ({ key }));
+ },
+ }));
+};
+
+const defaultStores = new Map>();
+
+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);
diff --git a/packages/react-openapi/src/useSyncedTabsGlobalState.ts b/packages/react-openapi/src/useSyncedTabsGlobalState.ts
deleted file mode 100644
index 42a6c3a09..000000000
--- a/packages/react-openapi/src/useSyncedTabsGlobalState.ts
+++ /dev/null
@@ -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()((set) => ({
- tabKey: initialTab ?? null,
- setTabKey: (tabKey) => {
- set(() => ({ tabKey }));
- },
- }));
-};
-
-const defaultTabStores = new Map>();
-
-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);