Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve OpenAPI codesample #3090

Merged
merged 6 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pink-students-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---

Improve OpenAPI codesample (add OpenAPISelect component)
117 changes: 100 additions & 17 deletions packages/gitbook/src/components/DocumentView/OpenAPI/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -419,8 +419,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 {
Expand All @@ -433,7 +445,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;
}
Expand All @@ -447,12 +459,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;
}

Expand All @@ -464,14 +476,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;
}
Expand All @@ -498,7 +502,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;
}

Expand All @@ -513,7 +518,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 {
Expand Down Expand Up @@ -576,8 +631,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 */
Expand Down Expand Up @@ -727,6 +784,32 @@
}
}

@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;
@apply hover:brightness-95 cursor-pointer;
}

.openapi-copy-button[data-disabled="true"] {
@apply cursor-default;
}
18 changes: 10 additions & 8 deletions packages/react-openapi/src/InteractiveSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>
Expand Down
13 changes: 3 additions & 10 deletions packages/react-openapi/src/OpenAPICodeSample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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} />;
}

/**
Expand Down
69 changes: 43 additions & 26 deletions packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]),
};
}

Expand Down Expand Up @@ -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>
);
}

Expand All @@ -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>
);
}

Expand Down
Loading