Skip to content

feat(Tabs): add renderTabs for library-agnostic drag-and-drop#2723

Draft
korvin89 wants to merge 3 commits into
mainfrom
feat/tabs-render-tabs
Draft

feat(Tabs): add renderTabs for library-agnostic drag-and-drop#2723
korvin89 wants to merge 3 commits into
mainfrom
feat/tabs-render-tabs

Conversation

@korvin89

@korvin89 korvin89 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🤖 AI generated

Add a renderTabs prop to TabList — a small, drag-and-drop-library-agnostic seam that lets consumers wrap the visible tabs in any DnD library, including with contentOverflow="collapse".

Why

Wrapping tabs in a DnD library externally breaks with contentOverflow="collapse": the collapse split runs on the raw <Tab> children, so <Draggable> wrappers (which hide the <Tab> behind a render function) plus the extra dnd placeholder node get miscounted — phantom "More", wrong collapsed count, and an empty overflow menu.

Approach

TabList keeps computing the shown/collapsed split from the raw <Tab> children and hands only the already-computed visible tabs to renderTabs. Collapsed tabs stay plain <Tab> in the overflow menu. No DnD-library types leak into the component.

renderTabs?: (tabs: React.ReactElement[]) => React.ReactNode;
  • Works in both contentOverflow modes; the collapse More menu keeps working.
  • Consumer wraps each visible tab in their lib's draggable (no wrapper DOM) and renders the placeholder inside renderTabs.
  • A dev-only check warns if the result breaks the contract (extra wrapper DOM / dropped tab).
  • Tab a11y (role, roving tabIndex, arrow navigation) is preserved; keyboard reordering stays with the DnD library.

Prior art: this mirrors Ant Design's renderTabBar escape hatch; no major library bakes DnD into tabs.

Stories DragAndDrop / DragAndDropCollapse (with @hello-pangea/dnd) demonstrate it.

Tests and README docs will follow once the approach is agreed.

Summary by Sourcery

Add a renderTabs escape hatch to TabList to support library-agnostic wrapping of visible tabs, including under collapsed overflow, with a dev-time contract check and Storybook demos using drag-and-drop.

New Features:

  • Introduce an optional renderTabs prop on TabList to allow consumers to wrap the already-computed visible tabs, including when contentOverflow="collapse" is enabled.

Enhancements:

  • Add a dev-only DOM-based validation that warns if renderTabs breaks the one-tab-per-child contract required for correct overflow measurement.

Tests:

  • Add Storybook examples demonstrating TabList integrated with @hello-pangea/dnd in both normal and contentOverflow="collapse" modes for drag-and-drop reordering of tabs.

@korvin89
korvin89 requested a review from sofiushko as a code owner July 1, 2026 19:01
@sourcery-ai

sourcery-ai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a new renderTabs escape hatch to TabList to enable library-agnostic drag-and-drop wrapping of visible tabs (including with contentOverflow="collapse"), plus Storybook examples and runtime validation of the contract.

File-Level Changes

Change Details Files
Introduce renderTabs prop on TabList to allow custom rendering/wrapping of visible tabs while preserving overflow/collapse behavior.
  • Add renderTabs?: (tabs: React.ReactElement[]) => React.ReactNode to TabListProps, with detailed contract docs about keys and DOM structure.
  • Compute shownTabs from collapsedChildrenResults.shownChildren and renderTabs, using it in both collapsed and non-collapsed contentOverflow branches.
  • Add a dev-only React.useEffect that inspects the DOM to verify renderTabs outputs exactly one .g-tab element per input tab, warning via warnOnce when the contract is violated.
  • Plumb renderTabs through useTabList props destructuring so it doesn’t leak into the DOM.
src/components/tabs/types.ts
src/components/tabs/TabList.tsx
src/components/tabs/hooks/useTabList.ts
Add Storybook demos showing drag-and-drop tabs integration using @hello-pangea/dnd, including with contentOverflow="collapse".
  • Import DragDropContext, Draggable, Droppable, and DropResult from @hello-pangea/dnd in TabList stories.
  • Implement a reusable DragAndDropRender story component that manages tab order in state, tracks visible tab values, and reorders tabs on drag end via a reorder helper.
  • Use the new renderTabs prop to wrap visible tabs in Draggable, clone Tab elements with drag props, and render the droppable placeholder, preserving keys and refs.
  • Export DragAndDrop and DragAndDropCollapse stories (the latter with contentOverflow="collapse" and constrained width) to validate behavior under overflow.
  • Add local CSS reset for the DnD placeholder button styling within the story container.
src/components/tabs/__stories__/TabList.stories.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@korvin89
korvin89 marked this pull request as draft July 1, 2026 19:01

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The useEffect that validates renderTabs has no dependency array, so it runs on every render; consider adding a dependency list (e.g. [renderTabs, shownChildren.length, bTab()]) or otherwise tightening it to avoid unnecessary DOM reads and warnings.
  • The renderTabs prop is typed as (tabs: React.ReactElement[]), but shownChildren can theoretically contain non-Tab elements; if that’s not supported, it might be safer to narrow/validate the element type or explicitly document that only Tab children are allowed and relied upon here.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `useEffect` that validates `renderTabs` has no dependency array, so it runs on every render; consider adding a dependency list (e.g. `[renderTabs, shownChildren.length, bTab()]`) or otherwise tightening it to avoid unnecessary DOM reads and warnings.
- The `renderTabs` prop is typed as `(tabs: React.ReactElement[])`, but `shownChildren` can theoretically contain non-`Tab` elements; if that’s not supported, it might be safer to narrow/validate the element type or explicitly document that only `Tab` children are allowed and relied upon here.

## Individual Comments

### Comment 1
<location path="src/components/tabs/TabList.tsx" line_range="60-78" />
<code_context>
+    // Dev-only validation of the `renderTabs` result: `warnOnce` already gates on
+    // NODE_ENV and dedupes. We check the actual DOM (the element tree can't be validated
+    // reliably — dnd libraries hide the `<Tab>` behind a render function).
+    React.useEffect(() => {
+        if (!renderTabs || !listRef.current) {
+            return;
+        }
+        const tabClassName = bTab();
+        const shownCount = Array.from(listRef.current.children).filter((child) =>
+            child.classList.contains(tabClassName),
+        ).length;
+        if (shownCount !== shownChildren.length) {
+            warnOnce(
+                'TabList: `renderTabs` must render exactly one visible tab per received element ' +
+                    '(preserve keys, no extra wrapper DOM around a tab). A wrapper element breaks ' +
+                    'the `contentOverflow="collapse"` measurement.',
+            );
+        }
+    });
+
     return (
</code_context>
<issue_to_address>
**suggestion (performance):** The validation effect is missing a dependency array and will run after every render.

Since this dev-only effect only depends on `renderTabs`, `shownChildren.length`, and `listRef.current`, it’s doing extra work by running after every render. Consider adding a dependency array such as `[renderTabs, shownChildren.length]` and checking `listRef.current` inside the effect, so it only runs when the relevant inputs change.

```suggestion
    // Dev-only validation of the `renderTabs` result: `warnOnce` already gates on
    // NODE_ENV and dedupes. We check the actual DOM (the element tree can't be validated
    // reliably — dnd libraries hide the `<Tab>` behind a render function).
    React.useEffect(() => {
        if (!renderTabs || !listRef.current) {
            return;
        }
        const tabClassName = bTab();
        const shownCount = Array.from(listRef.current.children).filter((child) =>
            child.classList.contains(tabClassName),
        ).length;
        if (shownCount !== shownChildren.length) {
            warnOnce(
                'TabList: `renderTabs` must render exactly one visible tab per received element ' +
                    '(preserve keys, no extra wrapper DOM around a tab). A wrapper element breaks ' +
                    'the `contentOverflow="collapse"` measurement.',
            );
        }
    }, [renderTabs, shownChildren.length]);
```
</issue_to_address>

### Comment 2
<location path="src/components/tabs/__stories__/TabList.stories.tsx" line_range="215-219" />
<code_context>
+                                );
+                                return (
+                                    <React.Fragment>
+                                        {shown.map((tab, index) => (
+                                            <Draggable
+                                                key={tab.key ?? index}
+                                                draggableId={String((tab.props as TabProps).value)}
+                                                index={index}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Using `index` as a fallback key can cause unnecessary remounting when tabs are reordered.

In a sortable list, index-based keys will change as items move, causing components to remount and lose internal state (including focus). Since you already have a stable `value` for `draggableId`, prefer a value-based key such as `key={tab.key ?? (tab.props as TabProps).value}` so keys stay consistent across reorders.

```suggestion
                                        {shown.map((tab, index) => (
                                            <Draggable
                                                key={tab.key ?? (tab.props as TabProps).value}
                                                draggableId={String((tab.props as TabProps).value)}
                                                index={index}
```
</issue_to_address>

### Comment 3
<location path="src/components/tabs/__stories__/TabList.stories.tsx" line_range="164" />
<code_context>
+ * `contentOverflow`) and hands them here to be wrapped in the DnD library of your choice.
+ * The collapsed tabs are still rendered as plain `<Tab>` in the overflow menu.
+ */
+const DragAndDropRender = (args: TabListProps) => {
+    const [tabs, setTabs] = React.useState<TabProps[]>(() => getTabsMock({}));
+    const [value, setValue] = React.useState(tabs[0].value);
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the drag-and-drop logic, tab-cloning helpers, and placeholder styling into small helper functions to make the `DragAndDropRender` story component easier to read and maintain.

You can keep the new functionality but reduce “god component” feel by extracting a few focused helpers. That will make the story much easier to scan.

**1. Extract DnD reorder logic**

Move the `onDragEnd` logic into a small pure helper so `DragAndDropRender` only wires it:

```ts
function reorderTabsByDragResult(
    tabs: TabProps[],
    shownValues: string[],
    {destination, draggableId}: DropResult,
): TabProps[] {
    if (!destination) return tabs;

    const toValue = shownValues[destination.index];
    const from = tabs.findIndex((tab) => tab.value === draggableId);
    const to = tabs.findIndex((tab) => tab.value === toValue);

    if (from === -1 || to === -1) return tabs;
    return reorder(tabs, from, to);
}
```

Then in `DragAndDropRender`:

```ts
const onDragEnd = (result: DropResult) => {
    setTabs((prev) => reorderTabsByDragResult(prev, shownValuesRef.current, result));
};
```

**2. Extract tab value / cloning helpers**

Avoid repeated casts and inline `cloneElement` noise:

```ts
function getTabValue(tab: React.ReactElement<TabProps>): string {
    return String((tab.props as TabProps).value);
}

function cloneTabWithDndProps(
    tab: React.ReactElement<TabProps>,
    provided: DraggableProvided,
): React.ReactElement<TabProps> {
    return React.cloneElement(tab, {
        ref: provided.innerRef,
        ...provided.draggableProps,
        ...provided.dragHandleProps,
    } as Partial<TabProps> & React.Attributes);
}
```

Usage in `renderTabs`:

```tsx
renderTabs={(shown) => {
    shownValuesRef.current = shown.map((tab) => (tab.props as TabProps).value);
    return (
        <>
            {shown.map((tab, index) => (
                <Draggable
                    key={tab.key ?? index}
                    draggableId={getTabValue(tab as React.ReactElement<TabProps>)}
                    index={index}
                    disableInteractiveElementBlocking
                >
                    {(draggableProvided) => cloneTabWithDndProps(
                        tab as React.ReactElement<TabProps>,
                        draggableProvided,
                    )}
                </Draggable>
            ))}
            {droppableProvided.placeholder}
        </>
    );
}}
```

**3. Extract placeholder styling**

Move the inline `<style>` into a small helper or constant so the JSX reads as layout only:

```ts
const dndPlaceholderReset = `
.tabs-dnd-story [data-rfd-placeholder-context-id] {
    -webkit-appearance: none;
    appearance: none;
    border: 0;
    background: transparent;
    padding: 0;
}
`;

function DndPlaceholderStyle() {
    return <style>{dndPlaceholderReset}</style>;
}
```

Then:

```tsx
<div className="tabs-dnd-story">
    <DndPlaceholderStyle />
    <DragDropContext onDragEnd={onDragEnd}>
        {/* ... */}
    </DragDropContext>
</div>
```

These small extractions keep all behavior the same but make `DragAndDropRender` mostly about “what is rendered” instead of “how it works,” reducing perceived complexity.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +60 to +78
// Dev-only validation of the `renderTabs` result: `warnOnce` already gates on
// NODE_ENV and dedupes. We check the actual DOM (the element tree can't be validated
// reliably — dnd libraries hide the `<Tab>` behind a render function).
React.useEffect(() => {
if (!renderTabs || !listRef.current) {
return;
}
const tabClassName = bTab();
const shownCount = Array.from(listRef.current.children).filter((child) =>
child.classList.contains(tabClassName),
).length;
if (shownCount !== shownChildren.length) {
warnOnce(
'TabList: `renderTabs` must render exactly one visible tab per received element ' +
'(preserve keys, no extra wrapper DOM around a tab). A wrapper element breaks ' +
'the `contentOverflow="collapse"` measurement.',
);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): The validation effect is missing a dependency array and will run after every render.

Since this dev-only effect only depends on renderTabs, shownChildren.length, and listRef.current, it’s doing extra work by running after every render. Consider adding a dependency array such as [renderTabs, shownChildren.length] and checking listRef.current inside the effect, so it only runs when the relevant inputs change.

Suggested change
// Dev-only validation of the `renderTabs` result: `warnOnce` already gates on
// NODE_ENV and dedupes. We check the actual DOM (the element tree can't be validated
// reliably — dnd libraries hide the `<Tab>` behind a render function).
React.useEffect(() => {
if (!renderTabs || !listRef.current) {
return;
}
const tabClassName = bTab();
const shownCount = Array.from(listRef.current.children).filter((child) =>
child.classList.contains(tabClassName),
).length;
if (shownCount !== shownChildren.length) {
warnOnce(
'TabList: `renderTabs` must render exactly one visible tab per received element ' +
'(preserve keys, no extra wrapper DOM around a tab). A wrapper element breaks ' +
'the `contentOverflow="collapse"` measurement.',
);
}
});
// Dev-only validation of the `renderTabs` result: `warnOnce` already gates on
// NODE_ENV and dedupes. We check the actual DOM (the element tree can't be validated
// reliably — dnd libraries hide the `<Tab>` behind a render function).
React.useEffect(() => {
if (!renderTabs || !listRef.current) {
return;
}
const tabClassName = bTab();
const shownCount = Array.from(listRef.current.children).filter((child) =>
child.classList.contains(tabClassName),
).length;
if (shownCount !== shownChildren.length) {
warnOnce(
'TabList: `renderTabs` must render exactly one visible tab per received element ' +
'(preserve keys, no extra wrapper DOM around a tab). A wrapper element breaks ' +
'the `contentOverflow="collapse"` measurement.',
);
}
}, [renderTabs, shownChildren.length]);

Comment on lines +215 to +219
{shown.map((tab, index) => (
<Draggable
key={tab.key ?? index}
draggableId={String((tab.props as TabProps).value)}
index={index}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Using index as a fallback key can cause unnecessary remounting when tabs are reordered.

In a sortable list, index-based keys will change as items move, causing components to remount and lose internal state (including focus). Since you already have a stable value for draggableId, prefer a value-based key such as key={tab.key ?? (tab.props as TabProps).value} so keys stay consistent across reorders.

Suggested change
{shown.map((tab, index) => (
<Draggable
key={tab.key ?? index}
draggableId={String((tab.props as TabProps).value)}
index={index}
{shown.map((tab, index) => (
<Draggable
key={tab.key ?? (tab.props as TabProps).value}
draggableId={String((tab.props as TabProps).value)}
index={index}

* `contentOverflow`) and hands them here to be wrapped in the DnD library of your choice.
* The collapsed tabs are still rendered as plain `<Tab>` in the overflow menu.
*/
const DragAndDropRender = (args: TabListProps) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the drag-and-drop logic, tab-cloning helpers, and placeholder styling into small helper functions to make the DragAndDropRender story component easier to read and maintain.

You can keep the new functionality but reduce “god component” feel by extracting a few focused helpers. That will make the story much easier to scan.

1. Extract DnD reorder logic

Move the onDragEnd logic into a small pure helper so DragAndDropRender only wires it:

function reorderTabsByDragResult(
    tabs: TabProps[],
    shownValues: string[],
    {destination, draggableId}: DropResult,
): TabProps[] {
    if (!destination) return tabs;

    const toValue = shownValues[destination.index];
    const from = tabs.findIndex((tab) => tab.value === draggableId);
    const to = tabs.findIndex((tab) => tab.value === toValue);

    if (from === -1 || to === -1) return tabs;
    return reorder(tabs, from, to);
}

Then in DragAndDropRender:

const onDragEnd = (result: DropResult) => {
    setTabs((prev) => reorderTabsByDragResult(prev, shownValuesRef.current, result));
};

2. Extract tab value / cloning helpers

Avoid repeated casts and inline cloneElement noise:

function getTabValue(tab: React.ReactElement<TabProps>): string {
    return String((tab.props as TabProps).value);
}

function cloneTabWithDndProps(
    tab: React.ReactElement<TabProps>,
    provided: DraggableProvided,
): React.ReactElement<TabProps> {
    return React.cloneElement(tab, {
        ref: provided.innerRef,
        ...provided.draggableProps,
        ...provided.dragHandleProps,
    } as Partial<TabProps> & React.Attributes);
}

Usage in renderTabs:

renderTabs={(shown) => {
    shownValuesRef.current = shown.map((tab) => (tab.props as TabProps).value);
    return (
        <>
            {shown.map((tab, index) => (
                <Draggable
                    key={tab.key ?? index}
                    draggableId={getTabValue(tab as React.ReactElement<TabProps>)}
                    index={index}
                    disableInteractiveElementBlocking
                >
                    {(draggableProvided) => cloneTabWithDndProps(
                        tab as React.ReactElement<TabProps>,
                        draggableProvided,
                    )}
                </Draggable>
            ))}
            {droppableProvided.placeholder}
        </>
    );
}}

3. Extract placeholder styling

Move the inline <style> into a small helper or constant so the JSX reads as layout only:

const dndPlaceholderReset = `
.tabs-dnd-story [data-rfd-placeholder-context-id] {
    -webkit-appearance: none;
    appearance: none;
    border: 0;
    background: transparent;
    padding: 0;
}
`;

function DndPlaceholderStyle() {
    return <style>{dndPlaceholderReset}</style>;
}

Then:

<div className="tabs-dnd-story">
    <DndPlaceholderStyle />
    <DragDropContext onDragEnd={onDragEnd}>
        {/* ... */}
    </DragDropContext>
</div>

These small extractions keep all behavior the same but make DragAndDropRender mostly about “what is rendered” instead of “how it works,” reducing perceived complexity.

@gravity-ui

gravity-ui Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Preview is ready.

@gravity-ui

gravity-ui Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🎭 Component Tests Report is ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant