diff --git a/packages/oc-docs/e2e/components/playground.component.ts b/packages/oc-docs/e2e/components/playground.component.ts index c47858fa..71c3a6f4 100644 --- a/packages/oc-docs/e2e/components/playground.component.ts +++ b/packages/oc-docs/e2e/components/playground.component.ts @@ -112,7 +112,13 @@ export class PlaygroundComponent extends BaseComponent { } async selectTab(id: string): Promise { - await this.tab(id).click(); + const direct = this.tab(id); + if ((await direct.count()) > 0 && (await direct.isVisible())) { + await direct.click(); + return; + } + await this.page.getByTestId('tabs-more').click(); + await this.page.getByTestId(`tabs-more-${id}`).click(); } async close(): Promise { diff --git a/packages/oc-docs/e2e/tests/playground/playground-tabs.spec.ts b/packages/oc-docs/e2e/tests/playground/playground-tabs.spec.ts new file mode 100644 index 00000000..33377ddb --- /dev/null +++ b/packages/oc-docs/e2e/tests/playground/playground-tabs.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '../../playwright'; + +test.describe('Playground request tabs — responsive overflow', () => { + test.use({ viewport: { width: 900, height: 800 } }); + + test('collapses overflowing tabs into a ⋯ dropdown and selects from it', async ({ page, playground }) => { + await playground.open('bottom'); + await playground.openRequest('get users'); + + await expect(page.getByTestId('tabs-more')).toBeVisible(); + + await playground.selectTab('tests'); + await expect(page.getByTestId('tabs-tab-tests')).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/packages/oc-docs/src/assets/icons/ChevronsRightIcon.tsx b/packages/oc-docs/src/assets/icons/ChevronsRightIcon.tsx new file mode 100644 index 00000000..44630e24 --- /dev/null +++ b/packages/oc-docs/src/assets/icons/ChevronsRightIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export const ChevronsRightIcon: React.FC<{ size?: number }> = ({ size = 18 }) => ( + +); + +export default ChevronsRightIcon; diff --git a/packages/oc-docs/src/assets/icons/DotIcon.tsx b/packages/oc-docs/src/assets/icons/DotIcon.tsx new file mode 100644 index 00000000..9eab4afb --- /dev/null +++ b/packages/oc-docs/src/assets/icons/DotIcon.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +export const DotIcon: React.FC<{ width?: number | string }> = ({ width = 10 }) => ( + +); + +export default DotIcon; diff --git a/packages/oc-docs/src/assets/icons/index.ts b/packages/oc-docs/src/assets/icons/index.ts index b72c9aec..12d225de 100644 --- a/packages/oc-docs/src/assets/icons/index.ts +++ b/packages/oc-docs/src/assets/icons/index.ts @@ -27,4 +27,6 @@ export * from './SidebarToggleIcon'; export * from './SettingsIcon'; export * from './TrashIcon'; export * from './ExampleIcon'; -export * from './CheckIcon'; \ No newline at end of file +export * from './DotIcon'; +export * from './ChevronsRightIcon'; +export * from './CheckIcon'; diff --git a/packages/oc-docs/src/components/Docs/Sidebar/SidebarNavLink/SidebarNavLink.spec.tsx b/packages/oc-docs/src/components/Docs/Sidebar/SidebarNavLink/SidebarNavLink.spec.tsx index 1f45e760..1b4f0021 100644 --- a/packages/oc-docs/src/components/Docs/Sidebar/SidebarNavLink/SidebarNavLink.spec.tsx +++ b/packages/oc-docs/src/components/Docs/Sidebar/SidebarNavLink/SidebarNavLink.spec.tsx @@ -52,10 +52,11 @@ describe('SidebarNavLink', () => { expect(html).not.toContain('navlink-label mono'); }); - it('indents by level via margin (chevron + gap step of 19px) with a uniform 8px inner pad', () => { + it('indents by level via margin (one 19px chevron+gap step per level) with a uniform 8px inner pad', () => { const html = renderToStaticMarkup(); - // level*19 = 38px -> 38/16 = 2.375rem margin; the 8px (0.5rem) pad is uniform - // across folders and leaves so glyphs line up under their parent. + // Each level is one 19px chevron+gap step: level*19 = 38px -> 38/16 = 2.375rem margin. + // The 8px (0.5rem) pad is uniform across folders and leaves so glyphs line up + // under their parent. expect(html).toContain('margin-left:2.375rem'); expect(html).toContain('padding-left:0.5rem'); }); diff --git a/packages/oc-docs/src/components/Playground/Content/Views/CollectionSettingsView/CollectionSettingsView.tsx b/packages/oc-docs/src/components/Playground/Content/Views/CollectionSettingsView/CollectionSettingsView.tsx index d26defc5..f74d67ce 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/CollectionSettingsView/CollectionSettingsView.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/CollectionSettingsView/CollectionSettingsView.tsx @@ -163,6 +163,7 @@ const CollectionSettings: React.FC = ({ collection }) =
({ ...tab, content:
{tab.content}
diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.spec.tsx b/packages/oc-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.spec.tsx index aad27adf..53b3eb8d 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.spec.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/HeadersTab/HeadersTab.spec.tsx @@ -24,16 +24,16 @@ describe('HeadersTab', () => { expect(values).toContain('text/html'); }); - it('renders a provided title and description', () => { + it('renders the provided description with no standalone title heading', () => { const root = useRenderToDom( ); - expect(query(root, '.title').text.trim()).toBe('Headers'); + // With no title prop, the tab shows only the description — no separate title heading. + expect(root.querySelector('.title')).toBeNull(); expect(query(root, '.description').text.trim()).toBe('Request headers sent with the call'); expect(root.querySelector('[data-testid="bulk-edit-toggle"]')).toBeTruthy(); }); diff --git a/packages/oc-docs/src/components/Playground/Content/Views/Common/ScriptsTab/ScriptsTab.spec.tsx b/packages/oc-docs/src/components/Playground/Content/Views/Common/ScriptsTab/ScriptsTab.spec.tsx index 7d0d56f1..b4d86885 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/Common/ScriptsTab/ScriptsTab.spec.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/Common/ScriptsTab/ScriptsTab.spec.tsx @@ -18,8 +18,9 @@ describe('ScriptsTab', () => { expect(query(root, '[data-testid="scripts-tabs-tab-post-response"]').text.trim()).toBe('Post response'); }); - it('renders the Tests section by default', () => { + it('renders the Tests section with no standalone title heading', () => { const root = useRenderToDom(); + expect(root.querySelector('.title')).toBeNull(); expect(query(root, '.label').text.trim()).toBe('Tests'); }); diff --git a/packages/oc-docs/src/components/Playground/Content/Views/EnvironmentsView/EnvironmentsView.tsx b/packages/oc-docs/src/components/Playground/Content/Views/EnvironmentsView/EnvironmentsView.tsx index cd3d8311..988c9944 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/EnvironmentsView/EnvironmentsView.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/EnvironmentsView/EnvironmentsView.tsx @@ -217,7 +217,7 @@ const EnvironmentsView: React.FC = ({ collection, compact
{selectedEnvironment ? ( - + ) : (

Select an environment to view its variables

diff --git a/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.spec.tsx b/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.spec.tsx index 55184fa5..24fec69a 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.spec.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.spec.tsx @@ -43,6 +43,6 @@ describe('ExampleView', () => { const root = useRenderToDom(); const empty = root.querySelector('[data-testid="example-view-request-empty"]'); expect(empty).not.toBeNull(); - expect(empty!.textContent).toContain('No headers, body, auth, variables, or scripts configured for this request.'); + expect(empty!.textContent).toContain('No params, headers, or body configured for this request.'); }); }); diff --git a/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.tsx b/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.tsx index 1e5bef65..235305c0 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/ExampleView/ExampleView.tsx @@ -111,7 +111,7 @@ export const ExampleView: React.FC = ({ request, example, orie )} {!hasRequestData && (
- No header, body, params, or auth configured for the request. + No params, headers, or body configured for this request.
)}
diff --git a/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx b/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx index 274c4464..b8fdca32 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/FolderSettingsView/FolderSettingsView.tsx @@ -225,7 +225,7 @@ const FolderSettings: React.FC = ({ folder, onFolderChange
- +
); diff --git a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx index 41eba746..4e516859 100644 --- a/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx +++ b/packages/oc-docs/src/components/Playground/Content/Views/PlaygroundView/RequestPane/RequestPane.tsx @@ -279,6 +279,7 @@ const RequestPane: React.FC = ({ item, onItemChange }) => { return ( = ({ response, isLoading }) => { return ( alpha content

}, @@ -12,42 +12,42 @@ const tabs: Tab[] = [ describe('Tabs', () => { it('renders an accessible tablist with a tab button per item', () => { - const root = useRenderToDom(); + const root = useRenderToDom(); expect(root.querySelector('[role="tablist"]')).not.toBeNull(); expect(root.querySelectorAll('[role="tab"]')).toHaveLength(3); }); it('shows the label and its count', () => { - const root = useRenderToDom(); - const tab = query(root, '[data-testid="t-tab-a"]'); + const root = useRenderToDom(); + const tab = getByTestId(root, 'example-tab-a'); expect(tab.text).toContain('Alpha'); expect(query(tab, '.tab-count').text).toBe('2'); }); it('activates the first tab by default and mounts only its panel', () => { - const root = useRenderToDom(); - expect(query(root, '[data-testid="t-tab-a"]').getAttribute('aria-selected')).toBe('true'); - expect(query(root, '[data-testid="t-tab-b"]').getAttribute('aria-selected')).toBe('false'); + const root = useRenderToDom(); + expect(getByTestId(root, 'example-tab-a').getAttribute('aria-selected')).toBe('true'); + expect(getByTestId(root, 'example-tab-b').getAttribute('aria-selected')).toBe('false'); expect(root.querySelector('[data-testid="alpha"]')).not.toBeNull(); expect(root.querySelector('[data-testid="beta"]')).toBeNull(); }); it('honours a controlled activeTab', () => { - const root = useRenderToDom(); - expect(query(root, '[data-testid="t-tab-c"]').getAttribute('aria-selected')).toBe('true'); + const root = useRenderToDom(); + expect(getByTestId(root, 'example-tab-c').getAttribute('aria-selected')).toBe('true'); expect(root.querySelector('[data-testid="gamma"]')).not.toBeNull(); expect(root.querySelector('[data-testid="alpha"]')).toBeNull(); }); it('honours defaultActiveTab', () => { - const root = useRenderToDom(); + const root = useRenderToDom(); expect(root.querySelector('[data-testid="beta"]')).not.toBeNull(); }); it('applies roving tabindex (only the active tab is tabbable)', () => { - const root = useRenderToDom(); - expect(query(root, '[data-testid="t-tab-a"]').getAttribute('tabindex')).toBe('0'); - expect(query(root, '[data-testid="t-tab-b"]').getAttribute('tabindex')).toBe('-1'); + const root = useRenderToDom(); + expect(getByTestId(root, 'example-tab-a').getAttribute('tabindex')).toBe('0'); + expect(getByTestId(root, 'example-tab-b').getAttribute('tabindex')).toBe('-1'); }); it('renders the active tab’s rightElement, falling back to the shared one', () => { @@ -63,24 +63,38 @@ describe('Tabs', () => { }); it('wires each tab to its panel via aria-controls / aria-labelledby', () => { - const root = useRenderToDom(); - const tab = query(root, '[data-testid="t-tab-a"]'); + const root = useRenderToDom(); + const tab = getByTestId(root, 'example-tab-a'); const panel = query(root, '[role="tabpanel"]'); expect(tab.getAttribute('aria-controls')).toBe(panel.getAttribute('id')); expect(panel.getAttribute('aria-labelledby')).toBe(tab.getAttribute('id')); }); it('defaults to the underline variant', () => { - const root = useRenderToDom(); - const wrapper = query(root, '[data-testid="t"]'); + const root = useRenderToDom(); + const wrapper = getByTestId(root, 'example'); expect(wrapper.classList.contains('tabs-variant-underline')).toBe(true); expect(wrapper.classList.contains('tabs-variant-button')).toBe(false); }); it('applies the button variant class when variant="button"', () => { - const root = useRenderToDom(); - const wrapper = query(root, '[data-testid="t"]'); + const root = useRenderToDom(); + const wrapper = getByTestId(root, 'example'); expect(wrapper.classList.contains('tabs-variant-button')).toBe(true); expect(wrapper.classList.contains('tabs-variant-underline')).toBe(false); }); + + it('applies the responsive variant class when variant="responsive"', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'example').classList.contains('tabs-variant-responsive')).toBe(true); + }); + + it('renders every tab in the responsive variant until the client measures the row', () => { + const root = useRenderToDom(); + // Each tab renders, addressed by its test id (not by role/class). + expect(getByTestId(root, 'example-tab-a').text).toContain('Alpha'); + expect(getByTestId(root, 'example-tab-b').text).toContain('Beta'); + expect(getByTestId(root, 'example-tab-c').text).toContain('Gamma'); + expect(queryByTestId(root, 'example-measure')).not.toBeNull(); + }); }); diff --git a/packages/oc-docs/src/ui/Tabs/Tabs.tsx b/packages/oc-docs/src/ui/Tabs/Tabs.tsx index 3832e918..421278f6 100644 --- a/packages/oc-docs/src/ui/Tabs/Tabs.tsx +++ b/packages/oc-docs/src/ui/Tabs/Tabs.tsx @@ -1,4 +1,8 @@ import React, { useCallback, useRef, useState, type ReactNode } from 'react'; +import MenuDropdown from '../MenuDropdown/MenuDropdown'; +import type { MenuDropdownHandle } from '../MenuDropdown/types'; +import { ChevronsRightIcon, DotIcon } from '../../assets/icons'; +import { useResponsiveTabs } from './useResponsiveTabs'; import { StyledWrapper } from './StyledWrapper'; export interface Tab { @@ -17,13 +21,24 @@ interface TabsProps { defaultActiveTab?: string; onTabChange?: (id: string) => void; rightElement?: ReactNode; - variant?: 'underline' | 'button'; + variant?: 'underline' | 'button' | 'responsive'; className?: string; testId?: string; ariaLabel?: string; keepMounted?: boolean; } +const renderIndicator = (tab: Tab): ReactNode => { + const indicator = tab.count ?? tab.contentIndicator; + if (indicator === undefined) return null; + if (typeof indicator === 'number') return {indicator}; + return ( + + ); +}; + export const Tabs: React.FC = ({ tabs, activeTab, @@ -38,10 +53,22 @@ export const Tabs: React.FC = ({ }) => { const [internalActive, setInternalActive] = useState(defaultActiveTab ?? tabs[0]?.id ?? ''); const tabRefs = useRef>({}); + const moreRef = useRef(null); const requested = activeTab ?? internalActive; const current = tabs.some((tab) => tab.id === requested) ? requested : (tabs[0]?.id ?? ''); + const isResponsive = variant === 'responsive'; + const { containerRef, rightRef, setMeasureRef, visibleIds, overflowIds } = useResponsiveTabs( + tabs.map((tab) => tab.id), + current, + isResponsive + ); + + const visibleTabs = isResponsive ? tabs.filter((tab) => visibleIds.includes(tab.id)) : tabs; + const overflowTabs = isResponsive ? tabs.filter((tab) => overflowIds.includes(tab.id)) : []; + const navTabs = visibleTabs; + const activate = useCallback( (id: string) => { if (activeTab === undefined) setInternalActive(id); @@ -52,17 +79,17 @@ export const Tabs: React.FC = ({ const focusSibling = useCallback( (fromIndex: number, step: number) => { - const total = tabs.length; + const total = navTabs.length; for (let i = 1; i <= total; i += 1) { - const tab = tabs[(fromIndex + step * i + total) % total]; - if (!tab.disabled) { + const tab = navTabs[(fromIndex + step * i + total) % total]; + if (tab && !tab.disabled) { activate(tab.id); tabRefs.current[tab.id]?.focus(); return; } } }, - [tabs, activate] + [navTabs, activate] ); const onKeyDown = (event: React.KeyboardEvent, index: number) => { @@ -84,38 +111,89 @@ export const Tabs: React.FC = ({ const tabButtonId = (id: string) => `${testId}-tab-${id}`; const panelId = (id: string) => `${testId}-panel-${id}`; + const renderTabButton = (tab: Tab, navIndex: number) => { + const isActive = tab.id === current; + return ( + + ); + }; + + const overflowItems = overflowTabs.map((tab) => ({ + id: tab.id, + label: ( + + {tab.label} + {renderIndicator(tab)} + + ), + ariaLabel: tab.label, + onClick: () => { + activate(tab.id); + moreRef.current?.hide(); + } + })); + return ( - +
- {tabs.map((tab, index) => { - const isActive = tab.id === current; - const indicator = tab.count ?? tab.contentIndicator; - return ( - - ); - })} + + )}
- {rightContent !== undefined &&
{rightContent}
} + {rightContent !== undefined && ( +
+ {rightContent} +
+ )}
{keepMounted ? tabs.map((tab) => ( diff --git a/packages/oc-docs/src/ui/Tabs/useResponsiveTabs.ts b/packages/oc-docs/src/ui/Tabs/useResponsiveTabs.ts new file mode 100644 index 00000000..6dc4a876 --- /dev/null +++ b/packages/oc-docs/src/ui/Tabs/useResponsiveTabs.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +// Width (px) reserved for the trailing "⋯ more" dropdown trigger, and a small +// per-tab allowance so a tab that sits right on the boundary overflows rather +// than getting clipped. Mirrors the spacing used by Bruno's ResponsiveTabs. +const MORE_TRIGGER_RESERVE = 40; +const PER_TAB_ALLOWANCE = 16; + +const sameOrder = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((id, index) => id === b[index]); + +export interface ResponsiveTabsLayout { + /** Attach to a width-constrained ancestor (the tabs wrapper), not the tab row + * itself — the row grows to its content, so measuring it would be circular. */ + containerRef: React.RefObject; + /** Attach to the trailing right-content slot so its width is excluded. */ + rightRef: React.RefObject; + /** Ref callback for each hidden measurement tab, keyed by tab id. */ + setMeasureRef: (id: string) => (el: HTMLElement | null) => void; + visibleIds: string[]; + overflowIds: string[]; +} + +/** + * Splits a set of tabs into those that fit the container and those that overflow + * into a dropdown — the responsive behaviour of Bruno's `ResponsiveTabs`. Tabs are + * measured from hidden copies (their natural width), so the split reflects real + * rendered sizes. The active tab is always kept visible. SSR-safe: with no layout + * yet (server render / first paint) every tab is reported visible, so markup and + * unit tests still see the full set until the client measures. + */ +export const useResponsiveTabs = (ids: string[], activeId: string, enabled: boolean): ResponsiveTabsLayout => { + const containerRef = useRef(null); + const rightRef = useRef(null); + const measureRefs = useRef>({}); + const idsRef = useRef(ids); + idsRef.current = ids; + + const [visibleIds, setVisibleIds] = useState(ids); + const [overflowIds, setOverflowIds] = useState([]); + + const setMeasureRef = useCallback( + (id: string) => (el: HTMLElement | null) => { + measureRefs.current[id] = el; + }, + [] + ); + + // The measurement logic reads live values from refs so it can live in a ref and + // stay stable — the ResizeObserver below then mounts once instead of churning on + // every render (the request pane re-renders on each keystroke). + const recalcRef = useRef<() => void>(() => {}); + recalcRef.current = () => { + const container = containerRef.current; + const currentIds = idsRef.current; + if (!container || currentIds.length === 0) return; + + const rightWidth = rightRef.current?.offsetWidth ?? 0; + const available = container.offsetWidth - rightWidth - MORE_TRIGGER_RESERVE; + // Container not laid out yet — keep everything visible rather than hiding all. + if (available <= 0) { + setVisibleIds((prev) => (sameOrder(prev, currentIds) ? prev : currentIds)); + setOverflowIds((prev) => (prev.length === 0 ? prev : [])); + return; + } + + const visible: string[] = []; + const overflow: string[] = []; + let used = 0; + for (const id of currentIds) { + const width = (measureRefs.current[id]?.offsetWidth ?? 80) + PER_TAB_ALLOWANCE; + if (overflow.length === 0 && used + width <= available) { + visible.push(id); + used += width; + } else { + overflow.push(id); + } + } + + // The active tab must never hide inside the dropdown: promote it, demoting the + // last otherwise-visible tab to keep the count stable. + if (overflow.includes(activeId) && !visible.includes(activeId)) { + overflow.splice(overflow.indexOf(activeId), 1); + const demoted = visible.pop(); + if (demoted) overflow.unshift(demoted); + visible.push(activeId); + } + + setVisibleIds((prev) => (sameOrder(prev, visible) ? prev : visible)); + setOverflowIds((prev) => (sameOrder(prev, overflow) ? prev : overflow)); + }; + + 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. + useEffect(() => { + if (!enabled) { + setVisibleIds((prev) => (sameOrder(prev, idsRef.current) ? prev : idsRef.current)); + setOverflowIds((prev) => (prev.length === 0 ? prev : [])); + return; + } + const frame = requestAnimationFrame(() => recalcRef.current()); + return () => cancelAnimationFrame(frame); + }, [enabled, idsKey, activeId]); + + // Observe the container once per enable so a width change re-runs the split. + useEffect(() => { + if (!enabled || typeof ResizeObserver === 'undefined') return; + const container = containerRef.current; + if (!container) return; + let frame: number | null = null; + const observer = new ResizeObserver(() => { + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => recalcRef.current()); + }); + observer.observe(container); + return () => { + if (frame) cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [enabled]); + + return { + containerRef, + rightRef, + setMeasureRef, + visibleIds: enabled ? visibleIds : ids, + overflowIds: enabled ? overflowIds : [] + }; +};