From f774f9b3a0b64e2017768a3fb82ef58adeade428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Mon, 3 Mar 2025 18:11:54 +0100 Subject: [PATCH] Fix all tabs behaviour in OpenAPI blocks (#2912) --- .../react-openapi/src/InteractiveSection.tsx | 28 ++--- .../react-openapi/src/OpenAPICodeSample.tsx | 6 +- .../react-openapi/src/OpenAPIDisclosure.tsx | 1 + .../src/OpenAPIDisclosureGroup.tsx | 20 ++-- .../src/OpenAPIResponseExample.tsx | 23 ++-- .../react-openapi/src/OpenAPIResponses.tsx | 6 +- packages/react-openapi/src/OpenAPISchema.tsx | 4 +- packages/react-openapi/src/OpenAPISpec.tsx | 8 +- packages/react-openapi/src/OpenAPITabs.tsx | 106 +++++++----------- packages/react-openapi/src/StaticSection.tsx | 59 ++++++++++ .../src/useSyncedTabsGlobalState.ts | 48 +++++--- 11 files changed, 172 insertions(+), 137 deletions(-) create mode 100644 packages/react-openapi/src/StaticSection.tsx diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx index 8009b3dd3..2cc6681e3 100644 --- a/packages/react-openapi/src/InteractiveSection.tsx +++ b/packages/react-openapi/src/InteractiveSection.tsx @@ -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 ( -
{header ? ( -
{ if (toggeable) { state.toggle(); } }} - className={clsx('openapi-section-header', `${className}-header`)} + className={className} > -
+ {(children || selectedTab?.body) && toggeable ? (
+
) : null}
-
+ ) : null} {(!toggeable || state.isExpanded) && (children || selectedTab?.body) ? ( -
+ {children} {selectedTab?.body} -
+ ) : null} {overlay ? (
{overlay}
) : null} -
+ ); } diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 8cfba1716..c71a9e7b9 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -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 ( - } className="openapi-codesample"> + } className="openapi-codesample"> - + ); } diff --git a/packages/react-openapi/src/OpenAPIDisclosure.tsx b/packages/react-openapi/src/OpenAPIDisclosure.tsx index 19e958ddf..5cd7dad07 100644 --- a/packages/react-openapi/src/OpenAPIDisclosure.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosure.tsx @@ -1,3 +1,4 @@ +'use client'; import type React from 'react'; import { useRef } from 'react'; import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria'; diff --git a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx index f3b6fb431..37bc364d8 100644 --- a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx @@ -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(null); /** diff --git a/packages/react-openapi/src/OpenAPIResponseExample.tsx b/packages/react-openapi/src/OpenAPIResponseExample.tsx index cd2290198..d6bb785e7 100644 --- a/packages/react-openapi/src/OpenAPIResponseExample.tsx +++ b/packages/react-openapi/src/OpenAPIResponseExample.tsx @@ -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 ( - } className="openapi-response-example"> + } className="openapi-response-example"> - + ); } @@ -134,12 +134,9 @@ function OpenAPIResponse(props: { return ( - } - className="openapi-response-media-types" - > + } className="openapi-response-media-types"> - + ); } @@ -173,23 +170,19 @@ function OpenAPIResponseMediaType(props: { key: example.key, label: example.example.summary || example.key, body: ( - + ), }; }); return ( - } className="openapi-response-media-type-examples" > - + ); } diff --git a/packages/react-openapi/src/OpenAPIResponses.tsx b/packages/react-openapi/src/OpenAPIResponses.tsx index 89a59c00d..c434073c6 100644 --- a/packages/react-openapi/src/OpenAPIResponses.tsx +++ b/packages/react-openapi/src/OpenAPIResponses.tsx @@ -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 ( - + - + ); } diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index a98aa373c..93a628d26 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -119,9 +119,9 @@ export function OpenAPISchemaProperties(props: { return (
- {properties.map((property) => ( + {properties.map((property, index) => ( { return ( - - + ); })} diff --git a/packages/react-openapi/src/OpenAPITabs.tsx b/packages/react-openapi/src/OpenAPITabs.tsx index 698fb58b5..6640cd49d 100644 --- a/packages/react-openapi/src/OpenAPITabs.tsx +++ b/packages/react-openapi/src/OpenAPITabs.tsx @@ -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(null); @@ -35,68 +34,44 @@ export function OpenAPITabs( props: React.PropsWithChildren ) { 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(); - 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(defaultTab); - - const handleSelectionChange = (key: Key) => { - setSelectedTabKey(key); - if (stateKey) { - const tab = items.find((item) => item.key === key); - - if (!tab) { - return; + const [tabKey, setTabKey] = useState(() => { + 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 ( { + setTabKey(tabKey); + if (stateKey) { + const store = getOrCreateTabStoreByKey(stateKey); + store.setState({ tabKey }); + } + }} + selectedKey={tabKey} > {children} @@ -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() { {items.map((tab) => ( ({ 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} @@ -144,12 +124,10 @@ export function OpenAPITabsPanels() { return null; } + const key = selectedTab.key.toString(); + return ( - + {selectedTab.body} {selectedTab.description ? ( diff --git a/packages/react-openapi/src/StaticSection.tsx b/packages/react-openapi/src/StaticSection.tsx new file mode 100644 index 000000000..5124227e7 --- /dev/null +++ b/packages/react-openapi/src/StaticSection.tsx @@ -0,0 +1,59 @@ +import clsx from 'clsx'; +import { type ComponentPropsWithoutRef, forwardRef } from 'react'; + +export function Section(props: ComponentPropsWithoutRef<'div'>) { + return
; +} + +export function SectionHeader(props: ComponentPropsWithoutRef<'div'>) { + return ( +
+ ); +} + +export function SectionHeaderContent(props: ComponentPropsWithoutRef<'div'>) { + return ( +
+ ); +} + +export const SectionBody = forwardRef(function SectionBody( + props: ComponentPropsWithoutRef<'div'>, + ref: React.ForwardedRef +) { + return ( +
+ ); +}); + +export function StaticSection(props: { + className: string; + header: React.ReactNode; + children: React.ReactNode; +}) { + const { className, header, children } = props; + return ( +
+ + {header} + + {children} +
+ ); +} diff --git a/packages/react-openapi/src/useSyncedTabsGlobalState.ts b/packages/react-openapi/src/useSyncedTabsGlobalState.ts index fff4674bb..42a6c3a09 100644 --- a/packages/react-openapi/src/useSyncedTabsGlobalState.ts +++ b/packages/react-openapi/src/useSyncedTabsGlobalState.ts @@ -1,23 +1,35 @@ 'use client'; -import { create } from 'zustand'; +import { createStore } from 'zustand'; -interface SyncedTabsState { - tabs: Map; - setTabs: (updater: (tabs: Map) => Map) => void; -} +type Key = string | number; -const useSyncedTabsStore = create>()((set) => ({ - tabs: new Map(), - 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() { - const tabs = useSyncedTabsStore((state) => state.tabs as Map); - const setTabs = useSyncedTabsStore((state) => state.setTabs as SyncedTabsState['setTabs']); - return [tabs, setTabs] as const; -} +type TabActions = { setTabKey: (tab: Key | null) => void }; + +type TabStore = TabState & TabActions; + +const createTabStore = (initialTab?: Key) => { + return createStore()((set) => ({ + tabKey: initialTab ?? null, + setTabKey: (tabKey) => { + set(() => ({ tabKey })); + }, + })); +}; + +const defaultTabStores = new Map>(); + +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);