diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.spec.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.spec.tsx index 04db6996..22818a5e 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.spec.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.spec.tsx @@ -115,6 +115,16 @@ describe('ResponseActions', () => { }); }); + describe('buttons-only rendering', () => { + it('renders buttons-only with no testids or dropdown so the tab bar can measure its width', () => { + const root = render({ renderActionButtonsOnly: true }); + getByTestId(root, 'forceful-renderred-action-buttons'); + expect(query(root, '[aria-label="Copy Response"]')).toBeTruthy(); + expect(query(root, '[aria-label="Change Layout"]')).toBeTruthy(); + expect(root.querySelector('[aria-label="More actions"]')).toBeNull(); + }); + }); + describe('collapsed kebab menu', () => { it('renders the "More actions" dropdown trigger, closed by default', () => { const root = render(); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.tsx index 446ea7c7..654a58a4 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/ResponseActions.tsx @@ -1,12 +1,5 @@ import React, { useCallback } from 'react'; -import { - IconCopy, - IconDownload, - IconEraser, - IconLayoutColumns, - IconLayoutRows, - IconDots -} from '@tabler/icons'; +import { IconCopy, IconDownload, IconEraser, IconLayoutColumns, IconLayoutRows, IconDots } from '@tabler/icons'; import CopyResponse from './CopyResponse/CopyResponse'; import ClearResponse from './ClearResponse/ClearResponse'; import DownloadResponse from './DownloadResponse/DownloadResponse'; @@ -29,6 +22,7 @@ interface ResponseActionsProps { response: RunRequestResponse; selectedFormat: ResponseBodyFormat; showPreview: boolean; + renderActionButtonsOnly?: boolean; } const ResponseActions: React.FC = ({ @@ -36,10 +30,10 @@ const ResponseActions: React.FC = ({ itemUuid, response, selectedFormat, - showPreview + showPreview, + renderActionButtonsOnly = false }) => { - const getCopyText = useCallback(( - ): string => { + const getCopyText = useCallback((): string => { const data = response?.data; const dataBuffer = response?.base64Data; // Preview shows the raw data, so copy that as-is. @@ -50,12 +44,7 @@ const ResponseActions: React.FC = ({ return formatResponse(data, dataBuffer, selectedFormat); } return typeof data === 'string' ? data : JSON.stringify(data, null, 2); - }, [ - response?.data, - response?.base64Data, - selectedFormat, - showPreview - ]); + }, [response?.data, response?.base64Data, selectedFormat, showPreview]); const copyDisabled = !response.data; const { copied, copyResponse } = useCopy({ getText: getCopyText, @@ -71,7 +60,13 @@ const ResponseActions: React.FC = ({ const menuItems: MenuDropdownItem[] = [ { id: 'copy', label: 'Copy Response', leftSection: IconCopy, disabled: copyDisabled, onClick: copyResponse }, - { id: 'download', label: 'Download Response', leftSection: IconDownload, disabled: downloadDisabled, onClick: onDownload }, + { + id: 'download', + label: 'Download Response', + leftSection: IconDownload, + disabled: downloadDisabled, + onClick: onDownload + }, { id: 'clear', label: 'Clear Response', leftSection: IconEraser, onClick: onClear }, { id: 'layout', @@ -81,6 +76,31 @@ const ResponseActions: React.FC = ({ } ]; + const actionButtons = ( + <> + + + + + + ); + + // Width probe for the responsive tab bar: it needs the expanded buttons' width to decide whether + // to show them inline before it has committed to expanding. Rendered as buttons-only with no + // testids so tests and queries never confuse this copy with the live actions. + if (renderActionButtonsOnly) { + return ( + + ); + } + return (
@@ -91,10 +111,7 @@ const ResponseActions: React.FC = ({
- - - - + {actionButtons}
); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/StyledWrapper.ts index 506bb2bb..f9cab835 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/StyledWrapper.ts @@ -12,14 +12,15 @@ export const StyledWrapper = styled.div` height: 1.25rem; } - .expandable & { + .expandable &, + &.render-action-buttons-only { .actions-dropdown { display: none; } .actions-buttons { - display: flex; - align-items: center; - gap: 0.125rem; + display: flex; + align-items: center; + gap: 0.125rem; } } `; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/hooks/useResponseActions.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/hooks/useResponseActions.ts new file mode 100644 index 00000000..a6bd1774 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseActions/hooks/useResponseActions.ts @@ -0,0 +1,21 @@ +import { useCallback, useRef, useState } from 'react'; + +export default function useResponseActions() { + const [actionsExpandedWidth, setActionsExpandedWidth] = useState(); + const actionsMeasureObserver = useRef(null); + // Track the rendered width of a hidden expanded copy of the actions so the tab bar always has an + // up-to-date figure (it shifts with theme/font, not just at mount) without hardcoding a constant. + // The node is a measurement-only copy, so mark it inert to keep it out of focus/pointer/a11y — + // set imperatively because React 18 doesn't pass the `inert` prop through (see ExampleCard). + const measureActions = useCallback((node: HTMLDivElement | null) => { + actionsMeasureObserver.current?.disconnect(); + if (!node) return; + if (typeof ResizeObserver === 'undefined') return; + const update = () => setActionsExpandedWidth(node.offsetWidth || undefined); + update(); + actionsMeasureObserver.current = new ResizeObserver(update); + actionsMeasureObserver.current.observe(node); + }, []); + + return { actionsExpandedWidth, measureActions }; +} diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.tsx index e3dd998f..3132361d 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/ResponseFormatter.tsx @@ -10,6 +10,7 @@ import { FORMAT_ICONS } from '@/constants'; import PreviewToggleHeader from './PreviewToggleHeader/PreviewToggleHeader'; +import { StyledWrapper } from './StyledWrapper'; interface ResponseFormatSelectorProps { handleSelection?: (value: ResponseBodyFormat) => void; @@ -46,27 +47,28 @@ const ResponseFormatSelector: React.FC = ({ const TriggerIcon = showPreview ? IconEye : selectedFormat ? FORMAT_ICONS[selectedFormat] : undefined; return ( - ( - - {TriggerIcon && ( - - )} - {item.label} - - )} - placement="bottom-end" - header={} - testId="response-format-selector" - /> + + ( + + {TriggerIcon && ( + + )} + {item.label} + + )} + placement="bottom-end" + header={} + testId="response-format-selector" + /> + ); }; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/StyledWrapper.ts new file mode 100644 index 00000000..b7dbb30a --- /dev/null +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/StyledWrapper.ts @@ -0,0 +1,13 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + .menu-dropdown-trigger { + &:hover { + border-color: var(--oc-colors-text-subtext2); + } + + .menu-dropdown-trigger-label svg { + color: var(--oc-brand); + } + } +`; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx index 07997270..239ac322 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx @@ -14,7 +14,7 @@ import type { RunRequestResponse } from '@/runner'; import ResponseStatus from './ResponseInfo/ResponseStatus/ResponseStatus'; import ResponseSize from './ResponseInfo/ResponseSize/ResponseSize'; import ResponseActions from './ResponseActions/ResponseActions'; -import { RESPONSE_ACTIONS_EXPANDED_WIDTH } from '@/constants/response'; +import useResponseActions from './ResponseActions/hooks/useResponseActions'; interface ResponsePaneProps { response: RunRequestResponse; @@ -25,6 +25,8 @@ interface ResponsePaneProps { const ResponsePane: React.FC = ({ response, isLoading, orientation, itemUuid }) => { const [activeTab, setActiveTab] = useState('response'); + const { actionsExpandedWidth, measureActions } = useResponseActions(); + const { selectedFormat, showPreview, @@ -110,11 +112,14 @@ const ResponsePane: React.FC = ({ response, isLoading, orient } ]; - // The status metadata and the actions are separate direct children of the tab bar's right slot so - // the responsive tab bar can measure the actions block (the last child) to decide whether to show - // it as inline buttons or collapse it into a menu. + // MUST stay a fragment: the format selector, status metadata, and actions have to be *direct* + // children of the tab bar's right slot. The responsive tab bar measures the leading children live + // and swaps in a supplied width only for the actions (the last child) to choose inline buttons vs. + // a collapsed menu. Wrapping these in a container collapses them into one child, so that model + // discards the format/status widths and the actions stop collapsing (see useResponsiveTabs). To + // adjust spacing between the groups, set `.tabs-right { gap }` in this pane's StyledWrapper. const statusInfo = ( -
+ <> {activeTab === 'response' && ( = ({ response, isLoading, orient toggleView={toggleView} /> )} -
+
@@ -136,11 +141,23 @@ const ResponsePane: React.FC = ({ response, isLoading, orient selectedFormat={selectedFormat} showPreview={showPreview} /> -
+ ); return ( + {!response.error && ( + + )} = ({ response, isLoading, orient tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} - rightElement={response.error ? undefined : statusInfo} - rightContentExpandedWidth={RESPONSE_ACTIONS_EXPANDED_WIDTH} + rightElement={response.error ? null : statusInfo} + rightContentExpandedWidth={actionsExpandedWidth} /> ); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/StyledWrapper.ts index 0cbd2525..95847dba 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/StyledWrapper.ts @@ -10,6 +10,18 @@ export const StyledWrapper = styled.div` overflow-y: auto; } + .tabs-right { + gap: 0.75rem; + } + + .response-actions-measure { + position: absolute; + top: 0; + left: 0; + visibility: hidden; + pointer-events: none; + } + & .send-icon { padding: 0.5625rem; border-radius: 50%; diff --git a/packages/bruno-api-docs/src/constants/response.ts b/packages/bruno-api-docs/src/constants/response.ts index b019c84f..3d236817 100644 --- a/packages/bruno-api-docs/src/constants/response.ts +++ b/packages/bruno-api-docs/src/constants/response.ts @@ -219,7 +219,3 @@ export const FORMAT_ICONS: Record = { hex: IconHexagons, base64: IconBinaryTree }; - -// Width the actions block occupies when shown as buttons; the responsive tab bar uses it to -// decide whether to expand the actions inline or collapse them into a menu. -export const RESPONSE_ACTIONS_EXPANDED_WIDTH = 302; diff --git a/packages/bruno-api-docs/src/ui/Tabs/useResponsiveTabs.ts b/packages/bruno-api-docs/src/ui/Tabs/useResponsiveTabs.ts index b3bf11c4..daaf9bca 100644 --- a/packages/bruno-api-docs/src/ui/Tabs/useResponsiveTabs.ts +++ b/packages/bruno-api-docs/src/ui/Tabs/useResponsiveTabs.ts @@ -76,13 +76,11 @@ export const useResponsiveTabs = ( let rightWidth = rightEl?.offsetWidth ?? 0; let rightModeled = false; if (rightContentExpandedWidth != null && rightEl != null && rightEl.children.length > 0) { - const children = rightEl.children; - const expandableIndex = children.length - 1; - let modeled = 0; - for (let i = 0; i < children.length; i += 1) { - modeled += i === expandableIndex ? rightContentExpandedWidth : (children[i] as HTMLElement).offsetWidth; - } - rightWidth = modeled; + // The trailing child (e.g. an actions block) can collapse to a compact form, so its live width + // understates the space it needs when expanded. Swap only that child's contribution for the + // supplied expanded width, taken from the slot's real width so inter-child gaps still count. + const expandable = rightEl.children[rightEl.children.length - 1] as HTMLElement; + rightWidth = rightEl.offsetWidth - expandable.offsetWidth + rightContentExpandedWidth; rightModeled = true; } @@ -129,9 +127,9 @@ export const useResponsiveTabs = ( const idsKey = ids.join(','); - // Recompute when responsiveness toggles, the tab set changes, or the active tab - // changes. idsKey/activeId are read through refs inside recalcRef, so they are - // listed here purely to re-run the measurement rather than referenced directly. + // Recompute when responsiveness toggles, the tab set changes, the active tab changes, or the + // supplied right-content expanded width updates. idsKey/activeId are read through refs inside + // recalcRef, so they are listed here purely to re-run the measurement rather than referenced directly. useEffect(() => { if (!enabled) { setVisibleIds((prev) => (sameOrder(prev, idsRef.current) ? prev : idsRef.current)); @@ -141,7 +139,7 @@ export const useResponsiveTabs = ( } const frame = requestAnimationFrame(() => recalcRef.current()); return () => cancelAnimationFrame(frame); - }, [enabled, idsKey, activeId]); + }, [enabled, idsKey, activeId, rightContentExpandedWidth]); // Observe only the container — the source of the available-width budget. Deliberately NOT the // right slot: its width is subtracted from that budget, but the trailing actions block collapses