mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Fix all tabs behaviour in OpenAPI blocks (#2912)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user