Skip to content

Commit c31cf52

Browse files
authored
Sync tabs across all OpenAPI blocks (#2817)
1 parent 0c03676 commit c31cf52

File tree

6 files changed

+72
-44
lines changed

6 files changed

+72
-44
lines changed

.changeset/fifty-donkeys-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
---
4+
5+
Sync tabs across all OpenAPI blocks

packages/react-openapi/src/InteractiveSection.tsx

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,17 @@
11
'use client';
22

33
import classNames from 'classnames';
4-
import React, { useCallback } from 'react';
4+
import React from 'react';
55
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
66
import { useDisclosureState } from 'react-stately';
7+
import { useSyncedTabsGlobalState } from './useSyncedTabsGlobalState';
78

89
interface InteractiveSectionTab {
910
key: string;
1011
label: string;
1112
body: React.ReactNode;
1213
}
1314

14-
let globalState: Record<string, string> = {};
15-
const listeners = new Set<() => void>();
16-
17-
function useSyncedTabsGlobalState() {
18-
const subscribe = useCallback((callback: () => void) => {
19-
listeners.add(callback);
20-
return () => listeners.delete(callback);
21-
}, []);
22-
23-
const getSnapshot = useCallback(() => globalState, []);
24-
25-
const setSyncedTabs = useCallback(
26-
(updater: (tabs: Record<string, string>) => Record<string, string>) => {
27-
globalState = updater(globalState);
28-
listeners.forEach((listener) => listener());
29-
},
30-
[],
31-
);
32-
33-
const tabs = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
34-
35-
return [tabs, setSyncedTabs] as const;
36-
}
37-
3815
/**
3916
* To optimize rendering, most of the components are server-components,
4017
* and the interactiveness is mainly handled by a few key components like this one.

packages/react-openapi/src/OpenAPICodeSample.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { generateMediaTypeExample, generateSchemaExample } from './generateSchem
66
import { InteractiveSection } from './InteractiveSection';
77
import { getServersURL } from './OpenAPIServerURL';
88
import { OpenAPIContextProps } from './types';
9+
import { createStateKey } from './utils';
910
import { stringifyOpenAPI } from './stringifyOpenAPI';
1011
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
1112
import { OpenAPIV3 } from '@scalar/openapi-types';
@@ -116,7 +117,7 @@ export function OpenAPICodeSample(props: {
116117
}
117118

118119
return (
119-
<OpenAPITabs items={samples}>
120+
<OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
120121
<InteractiveSection header={<OpenAPITabsList />} className="openapi-codesample">
121122
<OpenAPITabsPanels />
122123
</InteractiveSection>

packages/react-openapi/src/OpenAPIResponseExample.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
22
import { generateSchemaExample } from './generateSchemaExample';
33
import { OpenAPIContextProps } from './types';
4+
import { createStateKey } from './utils';
45
import { stringifyOpenAPI } from './stringifyOpenAPI';
56
import { OpenAPIV3 } from '@scalar/openapi-types';
67
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
@@ -110,7 +111,7 @@ export function OpenAPIResponseExample(props: {
110111
}
111112

112113
return (
113-
<OpenAPITabs items={examples}>
114+
<OpenAPITabs stateKey={createStateKey('response-example')} items={examples}>
114115
<InteractiveSection header={<OpenAPITabsList />} className="openapi-response-example">
115116
<OpenAPITabsPanels />
116117
</InteractiveSection>

packages/react-openapi/src/OpenAPITabs.tsx

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import React, { useMemo } from 'react';
44
import { Key, Tab, TabList, TabPanel, Tabs, TabsProps } from 'react-aria-components';
55
import { Markdown } from './Markdown';
6+
import { useSyncedTabsGlobalState } from './useSyncedTabsGlobalState';
67

78
export type Tab = {
89
key: Key;
@@ -13,8 +14,7 @@ export type Tab = {
1314

1415
type OpenAPITabsContextData = {
1516
items: Tab[];
16-
selectedKey: Key;
17-
setSelectedKey: (key: Key) => void;
17+
selectedTab: Tab;
1818
};
1919

2020
const OpenAPITabsContext = React.createContext<OpenAPITabsContextData | null>(null);
@@ -30,18 +30,39 @@ function useOpenAPITabsContext() {
3030
/**
3131
* The OpenAPI Tabs wrapper component.
3232
*/
33-
export function OpenAPITabs(props: React.PropsWithChildren<TabsProps & { items: Tab[] }>) {
34-
const { children, items } = props;
35-
const [selectedKey, setSelectedKey] = React.useState(items[0].key);
33+
export function OpenAPITabs(
34+
props: React.PropsWithChildren<TabsProps & { items: Tab[]; stateKey?: string }>,
35+
) {
36+
const { children, items, stateKey } = props;
3637

37-
const contextValue = { items, selectedKey, setSelectedKey };
38+
const [syncedTabs, setSyncedTabs] = useSyncedTabsGlobalState();
39+
const tabFromState =
40+
stateKey && stateKey in syncedTabs
41+
? items.find((tab) => tab.key === syncedTabs[stateKey])
42+
: undefined;
43+
const defaultTab = items[0]?.key;
44+
const [selectedTabKey, setSelectedTabKey] = React.useState(tabFromState?.key ?? defaultTab);
45+
46+
const selectedTab = tabFromState ?? items.find((tab) => tab.key === selectedTabKey) ?? items[0];
47+
48+
const contextValue = { items, selectedTab };
49+
50+
const handleSelectionChange = (key: Key) => {
51+
setSelectedTabKey(key);
52+
if (stateKey) {
53+
setSyncedTabs((state) => ({
54+
...state,
55+
[stateKey]: key.toString(),
56+
}));
57+
}
58+
};
3859

3960
return (
4061
<OpenAPITabsContext.Provider value={contextValue}>
4162
<Tabs
4263
className="openapi-tabs"
43-
onSelectionChange={setSelectedKey}
44-
selectedKey={selectedKey}
64+
onSelectionChange={handleSelectionChange}
65+
selectedKey={selectedTab.key}
4566
>
4667
{children}
4768
</Tabs>
@@ -83,23 +104,21 @@ export function OpenAPITabsList() {
83104
* It renders the content of the selected tab.
84105
*/
85106
export function OpenAPITabsPanels() {
86-
const { selectedKey, items } = useOpenAPITabsContext();
87-
88-
const tab = useMemo(() => items.find((tab) => tab.key === selectedKey), [items, selectedKey]);
107+
const { selectedTab } = useOpenAPITabsContext();
89108

90-
if (!tab) {
109+
if (!selectedTab) {
91110
return null;
92111
}
93112

94113
return (
95114
<TabPanel
96-
key={`TabPanel-${tab.key}`}
97-
id={tab.key.toString()}
115+
key={`TabPanel-${selectedTab.key}`}
116+
id={selectedTab.key.toString()}
98117
className="openapi-tabs-panel"
99118
>
100-
{tab.body}
101-
{tab.description ? (
102-
<Markdown source={tab.description} className="openapi-tabs-footer" />
119+
{selectedTab.body}
120+
{selectedTab.description ? (
121+
<Markdown source={selectedTab.description} className="openapi-tabs-footer" />
103122
) : null}
104123
</TabPanel>
105124
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
3+
let globalState: Record<string, string> = {};
4+
const listeners = new Set<() => void>();
5+
6+
export function useSyncedTabsGlobalState() {
7+
const subscribe = React.useCallback((callback: () => void) => {
8+
listeners.add(callback);
9+
return () => listeners.delete(callback);
10+
}, []);
11+
12+
const getSnapshot = React.useCallback(() => globalState, []);
13+
14+
const setSyncedTabs = React.useCallback(
15+
(updater: (tabs: Record<string, string>) => Record<string, string>) => {
16+
globalState = updater(globalState);
17+
listeners.forEach((listener) => listener());
18+
},
19+
[],
20+
);
21+
22+
const tabs = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
23+
24+
return [tabs, setSyncedTabs] as const;
25+
}

0 commit comments

Comments
 (0)