Skip to content

Commit cbd768a

Browse files
authored
Improve OpenAPI codesample (#3090)
1 parent 2d01653 commit cbd768a

12 files changed

+385
-110
lines changed

Diff for: .changeset/pink-students-grow.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
'gitbook': patch
4+
---
5+
6+
Improve OpenAPI codesample (add OpenAPISelect component)

Diff for: packages/gitbook/src/components/DocumentView/OpenAPI/style.css

+100-17
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090

9191
/* Method Tags */
9292
.openapi-method {
93-
@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;
93+
@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;
9494
}
9595

9696
.openapi-method-get {
@@ -423,8 +423,20 @@
423423
@apply flex flex-row items-center;
424424
}
425425

426+
.openapi-codesample-header .openapi-select > button {
427+
@apply border-none;
428+
}
429+
426430
.openapi-codesample-header-content {
427-
@apply flex flex-row items-center h-fit;
431+
@apply flex flex-row items-center justify-between h-fit p-2.5;
432+
}
433+
434+
.openapi-codesample-header-content .openapi-path {
435+
@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;
436+
}
437+
438+
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
439+
@apply text-[0.813rem];
428440
}
429441

430442
.openapi-codesample-footer {
@@ -437,7 +449,7 @@
437449

438450
/* Path */
439451
.openapi-path {
440-
@apply flex items-start text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
452+
@apply flex items-center text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
441453
scrollbar-width: none;
442454
-ms-overflow-style: none;
443455
}
@@ -451,12 +463,12 @@
451463
}
452464

453465
.openapi-path .openapi-method {
454-
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-2;
466+
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-1;
455467
}
456468

457469
.openapi-path-title {
458470
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
459-
@apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors;
471+
@apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
460472
@apply whitespace-nowrap md:whitespace-normal;
461473
}
462474

@@ -468,14 +480,6 @@
468480
display: none;
469481
}
470482

471-
/* .openapi-path-copy {
472-
@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;
473-
}
474-
475-
.openapi-path-title:hover .openapi-path-copy {
476-
@apply opacity-11;
477-
} */
478-
479483
.openapi-path-title em {
480484
@apply not-italic text-primary font-medium;
481485
}
@@ -502,7 +506,8 @@
502506
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
503507
}
504508

505-
.openapi-panel-footer {
509+
.openapi-panel-footer,
510+
.openapi-codesample-footer {
506511
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint;
507512
}
508513

@@ -517,7 +522,57 @@
517522

518523
/* Common Elements */
519524
.openapi-select {
520-
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 truncate border border-tint-subtle bg-tint;
525+
/* unstyled */
526+
}
527+
528+
/* Prevent react-aria popover from setting overflow:auto on body */
529+
body:has(.openapi-select-popover) {
530+
overflow: unset !important;
531+
}
532+
533+
.openapi-select > button {
534+
@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;
535+
@apply hover:bg-tint-hover transition-all;
536+
}
537+
538+
.openapi-select > button > span.react-aria-SelectValue {
539+
@apply shrink truncate;
540+
}
541+
542+
.openapi-select > button > .gb-icon {
543+
@apply shrink-0;
544+
}
545+
546+
.openapi-select > button svg {
547+
@apply size-2.5;
548+
}
549+
550+
.openapi-select-popover {
551+
@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;
552+
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
553+
}
554+
555+
.openapi-select-popover[data-entering] {
556+
animation: popover-enter 0.2s ease-in-out;
557+
}
558+
559+
.openapi-select-popover[data-exiting] {
560+
animation: popover-leave 0.2s ease-in-out;
561+
}
562+
563+
.openapi-select-item {
564+
@apply text-sm cursor-pointer px-1.5 py-0.5 truncate text-tint ring-0 border-none rounded !outline-none;
565+
@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;
566+
}
567+
568+
.openapi-select-item-selected {
569+
@apply text-primary-subtle hover:text-primary hover:bg-primary-hover;
570+
@apply theme-muted:hover:bg-primary-active theme-gradient:hover:bg-primary-active tint:font-semibold;
571+
@apply contrast-more:text-primary contrast-more:hover:text-primary-strong contrast-more:font-semibold;
572+
}
573+
574+
.openapi-select-listbox {
575+
@apply flex flex-col gap-1 focus:ring-0 focus:outline-none;
521576
}
522577

523578
.openapi-select:focus {
@@ -580,8 +635,10 @@
580635
@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;
581636
}
582637

583-
.openapi-tabs-panel {
638+
.openapi-tabs-panel,
639+
.openapi-codesample-panel {
584640
@apply flex-1 text-sm relative focus-visible:outline-none;
641+
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
585642
}
586643

587644
/* Disclosure group */
@@ -731,6 +788,32 @@
731788
}
732789
}
733790

791+
@keyframes popover-enter {
792+
0% {
793+
opacity: 0;
794+
transform: translateY(4px) scale(0.95);
795+
}
796+
100% {
797+
opacity: 1;
798+
transform: translateY(0) scale(1);
799+
}
800+
}
801+
802+
@keyframes popover-leave {
803+
0% {
804+
opacity: 1;
805+
transform: translateY(0) scale(1);
806+
}
807+
100% {
808+
opacity: 0;
809+
transform: translateY(4px) scale(0.95);
810+
}
811+
}
812+
734813
.openapi-copy-button {
735-
@apply hover:brightness-95;
814+
@apply hover:brightness-95 cursor-pointer;
815+
}
816+
817+
.openapi-copy-button[data-disabled="true"] {
818+
@apply cursor-default;
736819
}

Diff for: packages/react-openapi/src/InteractiveSection.tsx

+10-8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import clsx from 'clsx';
44
import { useRef, useState } from 'react';
55
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
66
import { useDisclosureState } from 'react-stately';
7+
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
78
import { Section, SectionBody, SectionHeader, SectionHeaderContent } from './StaticSection';
89

910
interface InteractiveSectionTab {
@@ -106,24 +107,25 @@ export function InteractiveSection(props: {
106107
}}
107108
>
108109
{tabs.length > 1 ? (
109-
<select
110+
<OpenAPISelect
110111
className={clsx(
111112
'openapi-section-select',
112-
'openapi-select',
113113
`${className}-tabs-select`
114114
)}
115-
value={selectedTab?.key ?? ''}
116-
onChange={(event) => {
117-
setSelectedTab(event.target.value);
115+
items={tabs}
116+
selectedKey={selectedTab?.key ?? ''}
117+
onSelectionChange={(key) => {
118+
setSelectedTab(String(key));
118119
state.expand();
119120
}}
121+
placement="bottom end"
120122
>
121123
{tabs.map((tab) => (
122-
<option key={tab.key} value={tab.key}>
124+
<OpenAPISelectItem key={tab.key} id={tab.key} value={tab}>
123125
{tab.label}
124-
</option>
126+
</OpenAPISelectItem>
125127
))}
126-
</select>
128+
</OpenAPISelect>
127129
) : null}
128130
</div>
129131
</SectionHeader>

Diff for: packages/react-openapi/src/OpenAPICodeSample.tsx

+3-10
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@ import {
33
OpenAPIMediaTypeExamplesBody,
44
OpenAPIMediaTypeExamplesSelector,
55
} from './OpenAPICodeSampleInteractive';
6-
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
6+
import { OpenAPICodeSampleBody } from './OpenAPICodeSampleSelector';
77
import { ScalarApiButton } from './ScalarApiButton';
8-
import { StaticSection } from './StaticSection';
98
import { type CodeSampleGenerator, codeSampleGenerators } from './code-samples';
109
import { generateMediaTypeExamples, generateSchemaExample } from './generateSchemaExample';
1110
import { stringifyOpenAPI } from './stringifyOpenAPI';
1211
import type { OpenAPIContext, OpenAPIOperationData } from './types';
1312
import { getDefaultServerURL } from './util/server';
14-
import { checkIsReference, createStateKey } from './utils';
13+
import { checkIsReference } from './utils';
1514

1615
const CUSTOM_CODE_SAMPLES_KEYS = ['x-custom-examples', 'x-code-samples', 'x-codeSamples'] as const;
1716

@@ -44,13 +43,7 @@ export function OpenAPICodeSample(props: {
4443
return null;
4544
}
4645

47-
return (
48-
<OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
49-
<StaticSection header={<OpenAPITabsList />} className="openapi-codesample">
50-
<OpenAPITabsPanels />
51-
</StaticSection>
52-
</OpenAPITabs>
53-
);
46+
return <OpenAPICodeSampleBody data={data} items={samples} />;
5447
}
5548

5649
/**

Diff for: packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx

+43-26
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import clsx from 'clsx';
33
import { useCallback } from 'react';
44
import { useStore } from 'zustand';
55
import type { MediaTypeRenderer } from './OpenAPICodeSample';
6-
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
6+
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
7+
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
78

89
type MediaTypeState = {
910
mediaType: string;
@@ -15,27 +16,27 @@ function useMediaTypeState(
1516
defaultKey: string
1617
): MediaTypeState {
1718
const { method, path } = data;
18-
const store = useStore(getOrCreateTabStoreByKey(`media-type-${method}-${path}`, defaultKey));
19-
if (typeof store.tabKey !== 'string') {
19+
const store = useStore(getOrCreateStoreByKey(`media-type-${method}-${path}`, defaultKey));
20+
if (typeof store.key !== 'string') {
2021
throw new Error('Media type key is not a string');
2122
}
2223
return {
23-
mediaType: store.tabKey,
24-
setMediaType: useCallback((index: string) => store.setTabKey(index), [store.setTabKey]),
24+
mediaType: store.key,
25+
setMediaType: useCallback((index: string) => store.setKey(index), [store.setKey]),
2526
};
2627
}
2728

2829
function useMediaTypeSampleIndexState(data: { method: string; path: string }, mediaType: string) {
2930
const { method, path } = data;
3031
const store = useStore(
31-
getOrCreateTabStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
32+
getOrCreateStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
3233
);
33-
if (typeof store.tabKey !== 'number') {
34+
if (typeof store.key !== 'number') {
3435
throw new Error('Example key is not a number');
3536
}
3637
return {
37-
index: store.tabKey,
38-
setIndex: useCallback((index: number) => store.setTabKey(index), [store.setTabKey]),
38+
index: store.key,
39+
setIndex: useCallback((index: number) => store.setKey(index), [store.setKey]),
3940
};
4041
}
4142

@@ -69,18 +70,28 @@ function MediaTypeSelector(props: {
6970
return null;
7071
}
7172

73+
const items = renderers.map((renderer) => ({
74+
key: renderer.mediaType,
75+
label: renderer.mediaType,
76+
}));
77+
7278
return (
73-
<select
79+
<OpenAPISelect
7480
className={clsx('openapi-select')}
75-
value={state.mediaType}
76-
onChange={(e) => state.setMediaType(e.target.value)}
81+
selectedKey={state.mediaType}
82+
items={renderers.map((renderer) => ({
83+
key: renderer.mediaType,
84+
label: renderer.mediaType,
85+
}))}
86+
onSelectionChange={(e) => state.setMediaType(String(e))}
87+
placement="bottom start"
7788
>
78-
{renderers.map((renderer) => (
79-
<option key={renderer.mediaType} value={renderer.mediaType}>
80-
{renderer.mediaType}
81-
</option>
89+
{items.map((item) => (
90+
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
91+
{item.label}
92+
</OpenAPISelectItem>
8293
))}
83-
</select>
94+
</OpenAPISelect>
8495
);
8596
}
8697

@@ -95,18 +106,24 @@ function ExamplesSelector(props: {
95106
return null;
96107
}
97108

109+
const items = renderer.examples.map((example, index) => ({
110+
key: index,
111+
label: example.example.summary || `Example ${index + 1}`,
112+
}));
113+
98114
return (
99-
<select
100-
className={clsx('openapi-select')}
101-
value={String(state.index)}
102-
onChange={(e) => state.setIndex(Number(e.target.value))}
115+
<OpenAPISelect
116+
items={items}
117+
selectedKey={state.index}
118+
onSelectionChange={(e) => state.setIndex(Number(e))}
119+
placement="bottom start"
103120
>
104-
{renderer.examples.map((example, index) => (
105-
<option key={index} value={index}>
106-
{example.example.summary || `Example ${index + 1}`}
107-
</option>
121+
{items.map((item) => (
122+
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
123+
{item.label}
124+
</OpenAPISelectItem>
108125
))}
109-
</select>
126+
</OpenAPISelect>
110127
);
111128
}
112129

0 commit comments

Comments
 (0)