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);