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 : []
+ };
+};