Fix all tabs behaviour in OpenAPI blocks (#2912)

This commit is contained in:
Greg Bergé
2025-03-03 18:11:54 +01:00
committed by GitHub
parent 29dc7ccb1a
commit f774f9b3a0
11 changed files with 172 additions and 137 deletions
@@ -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 { Section, SectionBody, SectionHeader, SectionHeaderContent } from './StaticSection';
interface InteractiveSectionTab {
key: string;
@@ -63,7 +64,7 @@ export function InteractiveSection(props: {
const { isFocusVisible, focusProps } = useFocusRing();
return (
<div
<Section
id={id}
className={clsx(
'openapi-section',
@@ -73,20 +74,15 @@ export function InteractiveSection(props: {
)}
>
{header ? (
<div
<SectionHeader
onClick={() => {
if (toggeable) {
state.toggle();
}
}}
className={clsx('openapi-section-header', `${className}-header`)}
className={className}
>
<div
className={clsx(
'openapi-section-header-content',
`${className}-header-content`
)}
>
<SectionHeaderContent className={className}>
{(children || selectedTab?.body) && toggeable ? (
<button
{...mergeProps(buttonProps, focusProps)}
@@ -102,7 +98,7 @@ export function InteractiveSection(props: {
</button>
) : null}
{header}
</div>
</SectionHeaderContent>
<div
className={clsx(
'openapi-section-header-controls',
@@ -133,23 +129,19 @@ export function InteractiveSection(props: {
</select>
) : null}
</div>
</div>
</SectionHeader>
) : null}
{(!toggeable || state.isExpanded) && (children || selectedTab?.body) ? (
<div
ref={panelRef}
{...panelProps}
className={clsx('openapi-section-body', `${className}-body`)}
>
<SectionBody ref={panelRef} {...panelProps} className={className}>
{children}
{selectedTab?.body}
</div>
</SectionBody>
) : null}
{overlay ? (
<div className={clsx('openapi-section-overlay', `${className}-overlay`)}>
{overlay}
</div>
) : null}
</div>
</Section>
);
}
@@ -1,5 +1,5 @@
import { InteractiveSection } from './InteractiveSection';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { StaticSection } from './StaticSection';
import { type CodeSampleInput, codeSampleGenerators } from './code-samples';
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
import { stringifyOpenAPI } from './stringifyOpenAPI';
@@ -117,9 +117,9 @@ export function OpenAPICodeSample(props: {
return (
<OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
<InteractiveSection header={<OpenAPITabsList />} className="openapi-codesample">
<StaticSection header={<OpenAPITabsList />} className="openapi-codesample">
<OpenAPITabsPanels />
</InteractiveSection>
</StaticSection>
</OpenAPITabs>
);
}
@@ -1,3 +1,4 @@
'use client';
import type React from 'react';
import { useRef } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
@@ -1,3 +1,14 @@
'use client';
import { createContext, useContext, useRef, useState } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing, useId } from 'react-aria';
import {
type DisclosureGroupProps,
type DisclosureGroupState,
useDisclosureGroupState,
useDisclosureState,
} from 'react-stately';
interface Props {
groups: TDisclosureGroup[];
icon?: React.ReactNode;
@@ -13,15 +24,6 @@ type TDisclosureGroup = {
}[];
};
import { createContext, useContext, useRef, useState } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing, useId } from 'react-aria';
import {
type DisclosureGroupProps,
type DisclosureGroupState,
useDisclosureGroupState,
useDisclosureState,
} from 'react-stately';
const DisclosureGroupStateContext = createContext<DisclosureGroupState | null>(null);
/**
@@ -1,6 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { StaticSection } from './StaticSection';
import { generateSchemaExample } from './generateSchemaExample';
import { json2xml } from './json2xml';
import { stringifyOpenAPI } from './stringifyOpenAPI';
@@ -84,9 +84,9 @@ export function OpenAPIResponseExample(props: {
return (
<OpenAPITabs stateKey={createStateKey('response-example')} items={tabs}>
<InteractiveSection header={<OpenAPITabsList />} className="openapi-response-example">
<StaticSection header={<OpenAPITabsList />} className="openapi-response-example">
<OpenAPITabsPanels />
</InteractiveSection>
</StaticSection>
</OpenAPITabs>
);
}
@@ -134,12 +134,9 @@ function OpenAPIResponse(props: {
return (
<OpenAPITabs stateKey={createStateKey('response-media-types')} items={tabs}>
<InteractiveSection
header={<OpenAPITabsList />}
className="openapi-response-media-types"
>
<StaticSection header={<OpenAPITabsList />} className="openapi-response-media-types">
<OpenAPITabsPanels />
</InteractiveSection>
</StaticSection>
</OpenAPITabs>
);
}
@@ -173,23 +170,19 @@ function OpenAPIResponseMediaType(props: {
key: example.key,
label: example.example.summary || example.key,
body: (
<OpenAPIExample
example={firstExample.example}
context={props.context}
syntax={syntax}
/>
<OpenAPIExample example={example.example} context={props.context} syntax={syntax} />
),
};
});
return (
<OpenAPITabs stateKey={createStateKey('response-media-type-examples')} items={tabs}>
<InteractiveSection
<StaticSection
header={<OpenAPITabsList />}
className="openapi-response-media-type-examples"
>
<OpenAPITabsPanels />
</InteractiveSection>
</StaticSection>
</OpenAPITabs>
);
}
@@ -1,8 +1,8 @@
import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown';
import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup';
import { OpenAPIResponse } from './OpenAPIResponse';
import { StaticSection } from './StaticSection';
import type { OpenAPIClientContext } from './types';
/**
@@ -15,7 +15,7 @@ export function OpenAPIResponses(props: {
const { responses, context } = props;
return (
<InteractiveSection header="Responses" className="openapi-responses">
<StaticSection header="Responses" className="openapi-responses">
<OpenAPIDisclosureGroup
allowsMultipleExpanded
icon={context.icons.chevronRight}
@@ -58,6 +58,6 @@ export function OpenAPIResponses(props: {
}
)}
/>
</InteractiveSection>
</StaticSection>
);
}
+2 -2
View File
@@ -119,9 +119,9 @@ export function OpenAPISchemaProperties(props: {
return (
<div id={id} className="openapi-schema-properties">
{properties.map((property) => (
{properties.map((property, index) => (
<OpenAPISchemaProperty
key={property.propertyName}
key={index}
circularRefs={circularRefs}
{...property}
context={context}
+3 -5
View File
@@ -1,12 +1,10 @@
'use client';
import type { OpenAPI } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPIRequestBody } from './OpenAPIRequestBody';
import { OpenAPIResponses } from './OpenAPIResponses';
import { OpenAPISchemaProperties } from './OpenAPISchema';
import { OpenAPISecurities } from './OpenAPISecurities';
import { StaticSection } from './StaticSection';
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
import { parameterToProperty } from './utils';
@@ -32,7 +30,7 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
{parameterGroups.map((group) => {
return (
<InteractiveSection
<StaticSection
key={group.key}
className="openapi-parameters"
header={group.label}
@@ -41,7 +39,7 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
properties={group.parameters.map(parameterToProperty)}
context={context}
/>
</InteractiveSection>
</StaticSection>
);
})}
+42 -64
View File
@@ -1,10 +1,9 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components';
import { useIntersectionObserver } from 'usehooks-ts';
import { Markdown } from './Markdown';
import { useSyncedTabsGlobalState } from './useSyncedTabsGlobalState';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
export type TabItem = {
key: Key;
@@ -15,7 +14,7 @@ export type TabItem = {
type OpenAPITabsContextData = {
items: TabItem[];
selectedTab: TabItem;
selectedTab: TabItem | null;
};
const OpenAPITabsContext = createContext<OpenAPITabsContextData | null>(null);
@@ -35,68 +34,44 @@ export function OpenAPITabs(
props: React.PropsWithChildren<TabsProps & { items: TabItem[]; stateKey?: string }>
) {
const { children, items, stateKey } = props;
const [ref, isIntersectionVisible] = useIntersectionObserver({
threshold: 0.1,
rootMargin: '200px',
});
const isVisible = stateKey ? isIntersectionVisible : true;
const defaultTab = items[0] as TabItem;
const [syncedTabs, setSyncedTabs] = useSyncedTabsGlobalState<TabItem>();
const [selectedTabKey, setSelectedTabKey] = useState(() => {
if (isVisible && stateKey && syncedTabs && syncedTabs.has(stateKey)) {
const tabFromState = syncedTabs.get(stateKey);
return tabFromState?.key ?? items[0]?.key;
}
return items[0]?.key;
});
const [selectedTab, setSelectedTab] = useState<TabItem>(defaultTab);
const handleSelectionChange = (key: Key) => {
setSelectedTabKey(key);
if (stateKey) {
const tab = items.find((item) => item.key === key);
if (!tab) {
return;
const [tabKey, setTabKey] = useState<Key | null>(() => {
if (stateKey && typeof window !== 'undefined') {
const store = getOrCreateTabStoreByKey(stateKey);
const tabKey = store.getState().tabKey;
if (tabKey) {
return tabKey;
}
setSyncedTabs((state) => {
const newState = new Map(state);
newState.set(stateKey, tab);
return newState;
});
}
};
return items[0]?.key ?? null;
});
const selectedTab = items.find((item) => item.key === tabKey) ?? items[0] ?? null;
const cancelDeferRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (isVisible && stateKey && syncedTabs && syncedTabs.has(stateKey)) {
const tabFromState = syncedTabs.get(stateKey);
if (!items.some((item) => item.key === tabFromState?.key)) {
return setSelectedTab(defaultTab);
}
if (tabFromState && tabFromState?.key !== selectedTab?.key) {
const tabFromItems = items.find((item) => item.key === tabFromState.key);
if (!tabFromItems) {
return;
}
setSelectedTab(tabFromItems);
}
if (!stateKey) {
return undefined;
}
}, [isVisible, stateKey, syncedTabs, selectedTabKey]);
const store = getOrCreateTabStoreByKey(stateKey);
return store.subscribe((state) => {
cancelDeferRef.current?.();
cancelDeferRef.current = defer(() => setTabKey(state.tabKey));
});
}, [stateKey]);
useEffect(() => {
return () => cancelDeferRef.current?.();
}, []);
const contextValue = useMemo(() => ({ items, selectedTab }), [items, selectedTab]);
return (
<OpenAPITabsContext.Provider value={contextValue}>
<Tabs
ref={ref}
className="openapi-tabs"
onSelectionChange={handleSelectionChange}
selectedKey={selectedTab?.key}
onSelectionChange={(tabKey) => {
setTabKey(tabKey);
if (stateKey) {
const store = getOrCreateTabStoreByKey(stateKey);
store.setState({ tabKey });
}
}}
selectedKey={tabKey}
>
{children}
</Tabs>
@@ -104,6 +79,11 @@ export function OpenAPITabs(
);
}
const defer = (fn: () => void) => {
const id = setTimeout(fn, 0);
return () => clearTimeout(id);
};
/**
* The OpenAPI Tabs list component.
* This component should be used as a child of the OpenAPITabs component.
@@ -116,14 +96,14 @@ export function OpenAPITabsList() {
<TabList className="openapi-tabs-list">
{items.map((tab) => (
<Tab
key={tab.key}
id={tab.key}
style={({ isFocusVisible }) => ({
outline: isFocusVisible
? '2px solid rgb(var(--primary-color-500)/0.4)'
: 'none',
})}
className="openapi-tabs-tab"
key={`Tab-${tab.key}`}
id={tab.key}
>
{tab.label}
</Tab>
@@ -144,12 +124,10 @@ export function OpenAPITabsPanels() {
return null;
}
const key = selectedTab.key.toString();
return (
<TabPanel
key={`TabPanel-${selectedTab.key}`}
id={selectedTab.key.toString()}
className="openapi-tabs-panel"
>
<TabPanel key={key} id={key} className="openapi-tabs-panel">
{selectedTab.body}
{selectedTab.description ? (
<Markdown source={selectedTab.description} className="openapi-tabs-footer" />
@@ -0,0 +1,59 @@
import clsx from 'clsx';
import { type ComponentPropsWithoutRef, forwardRef } from 'react';
export function Section(props: ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx('openapi-section', props.className)} />;
}
export function SectionHeader(props: ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
'openapi-section-header',
props.className && `${props.className}-header`
)}
/>
);
}
export function SectionHeaderContent(props: ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
'openapi-section-header-content',
props.className && `${props.className}-header-content`
)}
/>
);
}
export const SectionBody = forwardRef(function SectionBody(
props: ComponentPropsWithoutRef<'div'>,
ref: React.ForwardedRef<HTMLDivElement>
) {
return (
<div
ref={ref}
{...props}
className={clsx('openapi-section-body', props.className && `${props.className}-body`)}
/>
);
});
export function StaticSection(props: {
className: string;
header: React.ReactNode;
children: React.ReactNode;
}) {
const { className, header, children } = props;
return (
<Section className={className}>
<SectionHeader className={className}>
<SectionHeaderContent className={className}>{header}</SectionHeaderContent>
</SectionHeader>
<SectionBody className={className}>{children}</SectionBody>
</Section>
);
}
@@ -1,23 +1,35 @@
'use client';
import { create } from 'zustand';
import { createStore } from 'zustand';
interface SyncedTabsState<T> {
tabs: Map<string, T>;
setTabs: (updater: (tabs: Map<string, T>) => Map<string, T>) => void;
}
type Key = string | number;
const useSyncedTabsStore = create<SyncedTabsState<any>>()((set) => ({
tabs: new Map<string, any>(),
setTabs: (updater) =>
set((state) => ({
tabs: updater(new Map(state.tabs)), // Ensure a new Map is created for reactivity
})),
}));
type TabState = {
tabKey: Key | null;
};
// Selector for better performance - only re-renders when tabs change
export function useSyncedTabsGlobalState<T>() {
const tabs = useSyncedTabsStore((state) => state.tabs as Map<string, T>);
const setTabs = useSyncedTabsStore((state) => state.setTabs as SyncedTabsState<T>['setTabs']);
return [tabs, setTabs] as const;
}
type TabActions = { setTabKey: (tab: Key | null) => void };
type TabStore = TabState & TabActions;
const createTabStore = (initialTab?: Key) => {
return createStore<TabStore>()((set) => ({
tabKey: initialTab ?? null,
setTabKey: (tabKey) => {
set(() => ({ tabKey }));
},
}));
};
const defaultTabStores = new Map<string, ReturnType<typeof createTabStore>>();
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);