From 3bbc98a589df01eae4e4c85ecf3a81e1f4930150 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Fri, 4 Apr 2025 12:22:39 +0200 Subject: [PATCH 1/6] Improve OpenAPI codesample --- .../components/DocumentView/OpenAPI/style.css | 75 ++++++++++++++----- .../react-openapi/src/OpenAPICodeSample.tsx | 15 ++-- .../src/OpenAPICodeSampleInteractive.tsx | 57 ++++++++------ .../src/OpenAPICodeSampleSelector.tsx | 70 +++++++++++++++++ .../react-openapi/src/OpenAPIOperation.tsx | 2 +- packages/react-openapi/src/OpenAPIPath.tsx | 29 +++++-- packages/react-openapi/src/OpenAPISelect.tsx | 73 ++++++++++++++++++ packages/react-openapi/src/OpenAPITabs.tsx | 14 ++-- .../src/getOrCreateStoreByKey.ts | 35 +++++++++ .../src/useSyncedTabsGlobalState.ts | 35 --------- 10 files changed, 309 insertions(+), 96 deletions(-) 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/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index e0b5488e68..d52d4d0580 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 { @@ -420,7 +420,15 @@ } .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 { @@ -433,7 +441,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; } @@ -447,12 +455,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; } @@ -464,14 +472,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; } @@ -498,7 +498,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; } @@ -513,7 +514,41 @@ /* 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 svg { + @apply size-2.5; +} + +.openapi-select-popover { + @apply min-w-[var(--trigger-width)] max-w-40 w-full max-h-52 right-0 overflow-y-auto p-1.5 border border-tint-subtle bg-tint/9 backdrop-blur-xl rounded-md; + @apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1; +} + +.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; } .openapi-select:focus { @@ -576,8 +611,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 */ @@ -728,5 +765,9 @@ } .openapi-copy-button { - @apply hover:brightness-95; + @apply hover:brightness-95 cursor-pointer; +} + +.openapi-copy-button[data-disabled="true"] { + @apply cursor-default; } diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 5e36d50bdb..e6c9420e70 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -3,7 +3,7 @@ import { OpenAPIMediaTypeExamplesBody, OpenAPIMediaTypeExamplesSelector, } from './OpenAPICodeSampleInteractive'; -import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; +import { OpenAPICodeSampleBody, OpenAPICodeSampleHeader } from './OpenAPICodeSampleSelector'; import { ScalarApiButton } from './ScalarApiButton'; import { StaticSection } from './StaticSection'; import { type CodeSampleGenerator, codeSampleGenerators } from './code-samples'; @@ -11,7 +11,7 @@ import { generateMediaTypeExamples, generateSchemaExample } from './generateSche 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; @@ -45,11 +45,12 @@ export function OpenAPICodeSample(props: { } return ( - - } className="openapi-codesample"> - - - + } + className="openapi-codesample" + > + + ); } diff --git a/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx index 17cf16a62a..5679419c54 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]), }; } @@ -70,17 +71,21 @@ function MediaTypeSelector(props: { } return ( - + ); } @@ -95,18 +100,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 0000000000..f1bf90e4e7 --- /dev/null +++ b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx @@ -0,0 +1,70 @@ +'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 { getOrCreateStoreByKey } from './getOrCreateStoreByKey'; +import type { OpenAPIOperationData } from './types'; + +function useCodeSampleState(initialKey: Key = '') { + 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} + defaultSelectedKey={items[0]?.key} + > + {items.map((item) => ( + + {item.label} + + ))} + + ) : items[0] ? ( + {items[0].label} + ) : null} + + ); +} + +export function OpenAPICodeSampleBody(props: { items: CodeSampleItem[] }) { + const { items } = props; + const state = useCodeSampleState(items[0]?.key ?? ''); + + const selected = items.find((item) => item.key === state.key) || items[0]; + + return ( +
+ {selected?.body} + {selected?.footer ? selected.footer : null} +
+ ); +} diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx index 3d7a63eb65..f58375b1cf 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 ddc6893fe0..943163284b 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; + /** Wether to show the server URL. + * @default true + */ + withServer?: boolean; + /** + * Wether 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 0000000000..edece218c7 --- /dev/null +++ b/packages/react-openapi/src/OpenAPISelect.tsx @@ -0,0 +1,73 @@ +'use client'; + +import clsx from 'clsx'; +import { + Button, + type Key, + ListBox, + ListBoxItem, + type ListBoxItemProps, + Popover, + 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; +} + +export function OpenAPISelect(props: OpenAPISelectProps) { + const { items, children, className, selectedKey, onChange } = 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 6f8eec0c34..f3502ba1dd 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 0000000000..06f4cc44b3 --- /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 42a6c3a09d..0000000000 --- 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); From 4063b1a960b163203302c4f473986b52d8ce90ba Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Apr 2025 14:28:58 +0200 Subject: [PATCH 2/6] Move OpenAPICodeSample StaticSection --- .../components/DocumentView/OpenAPI/style.css | 38 ++++++++++++++++++- .../react-openapi/src/InteractiveSection.tsx | 17 +++++---- .../react-openapi/src/OpenAPICodeSample.tsx | 12 +----- .../src/OpenAPICodeSampleSelector.tsx | 33 ++++++++++++---- 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index d52d4d0580..80c59c563b 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -419,6 +419,10 @@ @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 justify-between h-fit p-2.5; } @@ -532,10 +536,18 @@ body:has(.openapi-select-popover) { } .openapi-select-popover { - @apply min-w-[var(--trigger-width)] max-w-40 w-full max-h-52 right-0 overflow-y-auto p-1.5 border border-tint-subtle bg-tint/9 backdrop-blur-xl rounded-md; + @apply min-w-32 max-w-44 w-fit max-h-52 right-0 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; @@ -548,7 +560,7 @@ body:has(.openapi-select-popover) { } .openapi-select-listbox { - @apply flex flex-col gap-1; + @apply flex flex-col gap-1 focus:ring-0 focus:outline-none; } .openapi-select:focus { @@ -764,6 +776,28 @@ body:has(.openapi-select-popover) { } } +@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; } diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx index 255ff53f06..47939afae6 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,24 @@ 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 e6c9420e70..9a99f970bc 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -3,9 +3,8 @@ import { OpenAPIMediaTypeExamplesBody, OpenAPIMediaTypeExamplesSelector, } from './OpenAPICodeSampleInteractive'; -import { OpenAPICodeSampleBody, OpenAPICodeSampleHeader } from './OpenAPICodeSampleSelector'; +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'; @@ -44,14 +43,7 @@ export function OpenAPICodeSample(props: { return null; } - return ( - } - className="openapi-codesample" - > - - - ); + return ; } /** diff --git a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx index f1bf90e4e7..5d7d17de93 100644 --- a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx +++ b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx @@ -5,10 +5,11 @@ 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 = '') { +function useCodeSampleState(initialKey: Key = 'default') { const store = useStore(getOrCreateStoreByKey('codesample', initialKey)); return { key: store.key, @@ -55,16 +56,32 @@ export function OpenAPICodeSampleHeader(props: { ); } -export function OpenAPICodeSampleBody(props: { items: CodeSampleItem[] }) { - const { items } = props; - const state = useCodeSampleState(items[0]?.key ?? ''); +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 ( -
- {selected?.body} - {selected?.footer ? selected.footer : null} -
+ } + className="openapi-codesample" + > +
+ {selected.body ? selected.body : null} + {selected.footer ? selected.footer : null} +
+
); } From e8c7891047e939f90475bbb777edf65b2f869b24 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Apr 2025 16:22:06 +0200 Subject: [PATCH 3/6] Fix OpenAPISelect --- .../components/DocumentView/OpenAPI/style.css | 10 ++++++++- .../react-openapi/src/InteractiveSection.tsx | 2 +- .../src/OpenAPICodeSampleInteractive.tsx | 22 ++++++++++++------- .../src/OpenAPICodeSampleSelector.tsx | 4 ++-- packages/react-openapi/src/OpenAPISelect.tsx | 13 +++++------ 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index 80c59c563b..1cc7c5f10e 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -531,12 +531,20 @@ body:has(.openapi-select-popover) { @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-44 w-fit max-h-52 right-0 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md; + @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; } diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx index 47939afae6..5fba447baa 100644 --- a/packages/react-openapi/src/InteractiveSection.tsx +++ b/packages/react-openapi/src/InteractiveSection.tsx @@ -114,7 +114,7 @@ export function InteractiveSection(props: { )} items={tabs} selectedKey={selectedTab?.key ?? ''} - onChange={(key) => { + onSelectionChange={(key) => { setSelectedTab(String(key)); state.expand(); }} diff --git a/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx index 5679419c54..6476cc42c8 100644 --- a/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx +++ b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx @@ -70,6 +70,11 @@ function MediaTypeSelector(props: { return null; } + const items = renderers.map((renderer) => ({ + key: renderer.mediaType, + label: renderer.mediaType, + })); + return ( state.setMediaType(String(e))} + onSelectionChange={(e) => state.setMediaType(String(e))} + placement="bottom start" > - {renderers.map((renderer) => ( - - {renderer.mediaType} + {items.map((item) => ( + + {item.label} ))} @@ -107,13 +113,13 @@ function ExamplesSelector(props: { return ( state.setIndex(Number(e))} + selectedKey={state.index} + onSelectionChange={(e) => state.setIndex(Number(e))} + placement="bottom start" > {items.map((item) => ( - + {item.label} ))} diff --git a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx index 5d7d17de93..f3f18ffc96 100644 --- a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx +++ b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx @@ -37,11 +37,11 @@ export function OpenAPICodeSampleHeader(props: { {items.length > 1 ? ( { + onSelectionChange={(key) => { state.setKey(key); }} items={items} - defaultSelectedKey={items[0]?.key} + placement="bottom end" > {items.map((item) => ( diff --git a/packages/react-openapi/src/OpenAPISelect.tsx b/packages/react-openapi/src/OpenAPISelect.tsx index edece218c7..14b83b77dd 100644 --- a/packages/react-openapi/src/OpenAPISelect.tsx +++ b/packages/react-openapi/src/OpenAPISelect.tsx @@ -8,6 +8,7 @@ import { ListBoxItem, type ListBoxItemProps, Popover, + type PopoverProps, Select, type SelectProps, SelectValue, @@ -23,18 +24,14 @@ interface OpenAPISelectProps extends Omit React.ReactNode); selectedKey?: Key; onChange?: (key: string | number) => void; + placement?: PopoverProps['placement']; } export function OpenAPISelect(props: OpenAPISelectProps) { - const { items, children, className, selectedKey, onChange } = props; + const { items, children, className, placement } = props; return ( - - + {children} From 45f7bdcfb6ae1309a7370ad94f055dc58f1fd43c Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Apr 2025 16:23:39 +0200 Subject: [PATCH 4/6] Changeset --- .changeset/pink-students-grow.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pink-students-grow.md diff --git a/.changeset/pink-students-grow.md b/.changeset/pink-students-grow.md new file mode 100644 index 0000000000..b15a3f18b7 --- /dev/null +++ b/.changeset/pink-students-grow.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-openapi': patch +'gitbook': patch +--- + +Improve OpenAPI codesample (add OpenAPISelect component) From 8e6a2b018491a951f8940a8dc83359010f79f34a Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Apr 2025 17:36:31 +0200 Subject: [PATCH 5/6] Correct spelling --- packages/react-openapi/src/OpenAPIPath.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-openapi/src/OpenAPIPath.tsx b/packages/react-openapi/src/OpenAPIPath.tsx index 943163284b..aa5857a5d6 100644 --- a/packages/react-openapi/src/OpenAPIPath.tsx +++ b/packages/react-openapi/src/OpenAPIPath.tsx @@ -7,12 +7,12 @@ import { getDefaultServerURL } from './util/server'; */ export function OpenAPIPath(props: { data: OpenAPIOperationData; - /** Wether to show the server URL. + /** Whether to show the server URL. * @default true */ withServer?: boolean; /** - * Wether the path is copyable. + * Whether the path is copyable. * @default true */ canCopy?: boolean; From f7a35f2430cc34438f3ad7296f6e4533e06fbb73 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 8 Apr 2025 09:20:23 +0200 Subject: [PATCH 6/6] Position for InteractiveSection Select --- packages/react-openapi/src/InteractiveSection.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx index 5fba447baa..8ae60649c9 100644 --- a/packages/react-openapi/src/InteractiveSection.tsx +++ b/packages/react-openapi/src/InteractiveSection.tsx @@ -118,6 +118,7 @@ export function InteractiveSection(props: { setSelectedTab(String(key)); state.expand(); }} + placement="bottom end" > {tabs.map((tab) => (