diff --git a/.changeset/rnd-11616-responses-selector-sync.md b/.changeset/rnd-11616-responses-selector-sync.md new file mode 100644 index 000000000..ab563228f --- /dev/null +++ b/.changeset/rnd-11616-responses-selector-sync.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Sync the API reference responses selector with the "Responses" collapsibles, and keep the selected response in sync across every operation on the page (like the code sample language selector). Selecting a status code now expands the matching response section and applies to all operations at once. diff --git a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx index 71413da3e..c62783fb9 100644 --- a/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx +++ b/packages/react-openapi/src/OpenAPIDisclosureGroup.tsx @@ -59,10 +59,11 @@ export function OpenAPIDisclosureGroup(props: DisclosureGroupProps & Props) { onExpandedChange, } = props; - const initialKeys = - expandedKeys || defaultExpandedKeys - ? new Set(expandedKeys || defaultExpandedKeys) - : undefined; + // When `expandedKeys` is provided, the group is controlled by the parent. Otherwise it owns + // its expanded state in a store keyed by `stateKey`. + const isControlled = expandedKeys !== undefined; + + const initialKeys = defaultExpandedKeys ? new Set(defaultExpandedKeys) : undefined; const { expandedKeys: storeExpandedKeys, setExpandedKeys } = useDisclosureGroupStore( stateKey, initialKeys @@ -70,9 +71,11 @@ export function OpenAPIDisclosureGroup(props: DisclosureGroupProps & Props) { const state = useDisclosureGroupState({ ...props, - expandedKeys: storeExpandedKeys, + expandedKeys: isControlled ? expandedKeys : storeExpandedKeys, onExpandedChange: (keys) => { - setExpandedKeys(keys); + if (!isControlled) { + setExpandedKeys(keys); + } onExpandedChange?.(keys); }, }); diff --git a/packages/react-openapi/src/OpenAPIResponseExample.tsx b/packages/react-openapi/src/OpenAPIResponseExample.tsx index 945ace4f1..794817d06 100644 --- a/packages/react-openapi/src/OpenAPIResponseExample.tsx +++ b/packages/react-openapi/src/OpenAPIResponseExample.tsx @@ -93,13 +93,7 @@ export function OpenAPIResponseExample(props: { return null; } - return ( - - ); + return ; } function OpenAPIResponse(props: { diff --git a/packages/react-openapi/src/OpenAPIResponseExampleContent.tsx b/packages/react-openapi/src/OpenAPIResponseExampleContent.tsx index 74c2f1142..58e4a7b92 100644 --- a/packages/react-openapi/src/OpenAPIResponseExampleContent.tsx +++ b/packages/react-openapi/src/OpenAPIResponseExampleContent.tsx @@ -14,42 +14,31 @@ type OpenAPIResponseExampleItem = OpenAPISelectItem & { /** * Get the state of the response examples select. */ -export function useResponseExamplesState( - blockKey: string | undefined, - initialKey: Key = 'default' -) { - return useSelectState(getResponseExampleStateKey(blockKey), initialKey); +export function useResponseExamplesState(initialKey: Key = 'default') { + return useSelectState(getResponseExampleStateKey(), initialKey); } export function OpenAPIResponseExampleContent(props: { items: OpenAPIResponseExampleItem[]; - blockKey?: string; selectIcon?: React.ReactNode; }) { - const { blockKey, items, selectIcon } = props; + const { items, selectIcon } = props; return ( - } + header={} className="openapi-response-examples" > - + ); } function OpenAPIResponseExampleHeader(props: { items: OpenAPIResponseExampleItem[]; - blockKey?: string; selectIcon?: React.ReactNode; }) { - const { items, blockKey, selectIcon } = props; + const { items, selectIcon } = props; if (items.length === 1) { const item = items[0]; @@ -69,7 +58,7 @@ function OpenAPIResponseExampleHeader(props: { {items.map((item) => ( @@ -103,10 +92,9 @@ function OpenAPIResponseExampleItem(props: { function OpenAPIResponseExampleBody(props: { items: OpenAPIResponseExampleItem[]; - blockKey?: string; }) { - const { blockKey, items } = props; - const state = useResponseExamplesState(blockKey, items[0]?.key); + const { items } = props; + const state = useResponseExamplesState(items[0]?.key); const selectedItem = items.find((item) => item.key === state.key) ?? items[0]; @@ -118,8 +106,9 @@ function OpenAPIResponseExampleBody(props: { } /** - * Return the state key for the response examples. + * Return the state key for the response examples. Not scoped to a block so the selected response + * stays in sync across every operation on the page (like the code sample language selector). */ -function getResponseExampleStateKey(blockKey: string | undefined) { - return createStateKey('openapi-responses', blockKey); +function getResponseExampleStateKey() { + return createStateKey('openapi-responses'); } diff --git a/packages/react-openapi/src/OpenAPIResponses.tsx b/packages/react-openapi/src/OpenAPIResponses.tsx index 0af72cb24..cac99e909 100644 --- a/packages/react-openapi/src/OpenAPIResponses.tsx +++ b/packages/react-openapi/src/OpenAPIResponses.tsx @@ -2,6 +2,7 @@ import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser'; import clsx from 'classnames'; +import type { Key } from 'react-aria'; import { Markdown } from './Markdown'; import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup'; import { OpenAPIResponse } from './OpenAPIResponse'; @@ -105,29 +106,47 @@ export function OpenAPIResponses(props: { }; }); - const state = useResponseExamplesState(context.blockKey, groups[0]?.key); + const state = useResponseExamplesState(groups[0]?.key); const expandAll = context.expandAllResponses; - const expandedKeys = expandAll - ? new Set(groups.map((g) => g.key)) - : state.key - ? new Set([state.key]) - : new Set(); + + // In expand-all mode the group manages its own (multiple) expanded rows; otherwise it's + // controlled and stays in sync with the page-wide responses selector. + const disclosureProps = expandAll + ? { + stateKey: createStateKey('openapi-responses-disclosure', context.blockKey), + defaultExpandedKeys: groups.map((g) => g.key), + } + : { + expandedKeys: getExpandedResponseKeys(groups, state.key), + onExpandedChange: (keys: Set) => { + state.setKey(keys.values().next().value ?? null); + }, + }; return ( { - const key = keys.values().next().value ?? null; - state.setKey(key); - }} groups={groups} selectIcon={context.icons.chevronDown} selectStateKey={createStateKey('response-media-types', context.blockKey)} + {...disclosureProps} /> ); } + +/** + * Resolve the expanded response for the controlled accordion: nothing when the selection is + * cleared, otherwise the selected status code, falling back to the first response when this + * operation doesn't define it. + */ +function getExpandedResponseKeys(groups: { key: string }[], selectedKey: Key | null): Set { + if (selectedKey == null) { + return new Set(); + } + const key = groups.find((g) => g.key === selectedKey)?.key ?? groups[0]?.key; + return key ? new Set([key]) : new Set(); +}