diff --git a/packages/react/src/html/ui/index.css b/packages/react/src/html/ui/index.css
index e385a3eb..c563527f 100644
--- a/packages/react/src/html/ui/index.css
+++ b/packages/react/src/html/ui/index.css
@@ -78,6 +78,33 @@ main {
}
}
+ .overload-panel {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing) * 4);
+ background-color: var(--color-neutral-100);
+ border: 1px solid var(--color-neutral-200);
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ border-bottom-left-radius: 0.25rem;
+ border-bottom-right-radius: 0.25rem;
+ padding: calc(var(--spacing) * 4);
+ padding-bottom: calc(var(--spacing) * 2);
+ }
+
+ @media (min-width: 48rem) {
+ .overload-panel {
+ gap: calc(var(--spacing) * 6);
+ padding: calc(var(--spacing) * 6);
+ padding-bottom: calc(var(--spacing) * 3);
+ }
+ }
+
+ :where([data-theme='dark'], [data-theme='dark'] *) .overload-panel {
+ background-color: var(--color-neutral-950);
+ border-color: var(--color-neutral-900);
+ }
+
table {
td {
word-break: break-all;
diff --git a/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs
index 1be67964..312eea9d 100644
--- a/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs
+++ b/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs
@@ -3,7 +3,11 @@ import { describe, it } from 'node:test';
import { setConfig } from '@doc-kit/core/utils/configuration/index.mjs';
-import { transformHeadingNode, gatherChangeEntries } from '../buildContent.mjs';
+import {
+ transformHeadingNode,
+ gatherChangeEntries,
+ groupOverloadsIntoTabs,
+} from '../buildContent.mjs';
const heading = {
type: 'heading',
@@ -190,3 +194,101 @@ describe('gatherChangeEntries', () => {
assert.equal(result[1].label, 'Added new feature.');
});
});
+
+describe('groupOverloadsIntoTabs', () => {
+ it('groups consecutive overloads into a single OverloadTabs component', () => {
+ const originalEntries = [
+ { heading: { data: { name: 'funcA', isOverload: false } } },
+ { heading: { depth: 3, data: { name: 'funcB', isOverload: false } } },
+ { heading: { depth: 3, data: { name: 'funcB', isOverload: true } } },
+ { heading: { depth: 3, data: { name: 'funcB', isOverload: true } } },
+ { heading: { data: { name: 'funcC', isOverload: false } } },
+ ];
+
+ const getText = node => {
+ if (node.type === 'text') {
+ return node.value;
+ }
+ return (node.children || []).map(getText).join('');
+ };
+
+ const makeNode = (className, bodyText, sigText = null) => {
+ const children = [
+ { type: 'element', tagName: 'h3', depth: 3 }, // The heading to be stripped
+ { type: 'text', value: bodyText },
+ ];
+
+ if (sigText) {
+ children.push({
+ type: 'element',
+ tagName: 'div',
+ properties: { class: 'signature', dataSignatureRaw: sigText },
+ });
+ }
+
+ return {
+ type: 'element',
+ tagName: 'div',
+ properties: { className },
+ children,
+ };
+ };
+
+ const processedChildren = [
+ makeNode('entry-a', 'body a'),
+ makeNode('entry-b1', 'body b1', 'function funcB(arg1);'),
+ makeNode('entry-b2', 'body b2', 'function funcB(arg1, arg2);'),
+ makeNode('entry-b3', 'body b3', 'function funcB(arg1, arg2, arg3);'),
+ makeNode('entry-c', 'body c'),
+ ];
+
+ const result = groupOverloadsIntoTabs(processedChildren, originalEntries);
+
+ // 0: funcA, 1: funcB-heading, 2: CombinedSignatures, 3: CodeTabs(funcB), 4: funcC
+ assert.equal(result.length, 5);
+
+ // First element is untouched
+ assert.equal(result[0].properties.className, 'entry-a');
+
+ // Second element is the extracted heading
+ assert.equal(result[1].tagName, 'h3');
+
+ // Third element is the combined signatures block
+ const combinedSigBlock = result[2];
+ assert.deepEqual(combinedSigBlock.properties.className, ['signature']);
+
+ // Assert that the combined signatures contain the raw signatures without comments
+ const combinedText = getText(combinedSigBlock);
+ assert.match(combinedText, /function funcB\(arg1\);/);
+ assert.match(combinedText, /function funcB\(arg1, arg2\);/);
+ assert.match(combinedText, /function funcB\(arg1, arg2, arg3\);/);
+
+ // Fourth element is the CodeTabs component
+ const tabsComponent = result[3];
+ assert.equal(tabsComponent.name, 'CodeTabs');
+ const languagesAttr = tabsComponent.attributes.find(
+ a => a.name === 'languages'
+ );
+ const displayNamesAttr = tabsComponent.attributes.find(
+ a => a.name === 'displayNames'
+ );
+ assert.equal(languagesAttr.value, 'overload|overload|overload');
+ assert.equal(displayNamesAttr.value, 'Overload #1|Overload #2|Overload #3');
+ assert.equal(tabsComponent.children.length, 3); // 3 tab panels
+
+ // Check that the h3 was removed from the overloads and they are wrapped in overload-panel
+ const panel1 = tabsComponent.children[0];
+ const classAttr1 = panel1.attributes.find(a => a.name === 'className');
+ assert.equal(classAttr1.value, 'overload-panel');
+
+ // Second panel child should be the text we inserted
+ assert.equal(panel1.children[0].value, 'body b1');
+ assert.equal(result[4].properties.className, 'entry-c');
+
+ const panel2 = tabsComponent.children[1];
+ const classAttr2 = panel2.attributes.find(a => a.name === 'className');
+ assert.equal(classAttr2.value, 'overload-panel');
+ assert.equal(panel2.children[0].type, 'text');
+ assert.equal(panel2.children[0].value, 'body b2');
+ });
+});
diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs
index 5b29a2f9..632e4d7e 100644
--- a/packages/react/src/jsx-ast/utils/buildContent.mjs
+++ b/packages/react/src/jsx-ast/utils/buildContent.mjs
@@ -6,6 +6,7 @@ import {
GITHUB_BLOB_URL,
populate,
} from '@doc-kit/core/utils/configuration/templates.mjs';
+import { highlighter } from '@doc-kit/core/utils/highlighter.mjs';
import { parseInline } from '@doc-kit/core/utils/inline.mjs';
import { omitKeys } from '@doc-kit/core/utils/misc.mjs';
import { UNIST } from '@doc-kit/core/utils/queries/index.mjs';
@@ -15,7 +16,7 @@ import { slice } from 'mdast-util-slice-markdown';
import { u as createTree } from 'unist-builder';
import { SKIP, visit } from 'unist-util-visit';
-import { createJSXElement } from './ast.mjs';
+import { createJSXElement, createAttributeNode } from './ast.mjs';
import { extractHeadings, extractTextContent } from './buildBarProps.mjs';
import { annotateOverloads } from './overloads.mjs';
import { getRemarkRecma as remark } from './remark.mjs';
@@ -317,6 +318,140 @@ export const processEntry = entry => {
return entry.content;
};
+/**
+ * Groups consecutive overloaded function API entries into a single OverloadTabs component.
+ * @param {Array} processedChildren - The processed JSX AST nodes for the API entries
+ * @param {Array} originalEntries - The original API metadata entries containing the overload flags
+ * @returns {Array} The final array of layout children with overloads grouped
+ */
+export const groupOverloadsIntoTabs = (processedChildren, originalEntries) => {
+ const finalChildren = [];
+ let activeOverloadGroup = null;
+
+ /**
+ * Wraps an AST node's children in a styled panel `div` for tab rendering.
+ * @param {import('estree').Node} rootNode - The root node whose children will be wrapped.
+ * @returns {import('estree').Node} The new `div` AST node containing the children.
+ */
+ const wrapInDiv = rootNode => {
+ return createJSXElement('div', {
+ inline: false,
+ className: 'overload-panel',
+ children: rootNode.children || [],
+ });
+ };
+
+ /**
+ * Extracts the raw signature string from an API entry node and removes the signature node from its children.
+ * @param {import('estree').Node} node - The AST node representing the API entry.
+ * @returns {string|null} The raw TypeScript signature string, or null if not found.
+ */
+ const extractSignature = node => {
+ const sigIdx = (node.children || []).findIndex(
+ c =>
+ c.properties?.className?.includes('signature') ||
+ c.properties?.class === 'signature'
+ );
+ if (sigIdx !== -1) {
+ const sigNode = node.children.splice(sigIdx, 1)[0];
+ return sigNode.properties?.dataSignatureRaw;
+ }
+ return null;
+ };
+
+ /**
+ * Finalizes the active overload group by generating a combined signatures block
+ */
+ const pushOverloadGroup = () => {
+ if (!activeOverloadGroup) {
+ return;
+ }
+
+ // Deduplicate signatures and join with a single newline
+ const uniqueSignatures = [...new Set(activeOverloadGroup.signatures)];
+ const combinedSigRaw = uniqueSignatures.join('\n');
+
+ const highlighted = highlighter.highlightToHast(
+ combinedSigRaw,
+ 'typescript'
+ );
+ const combinedSigNode = createElement('div', { class: 'signature' }, [
+ highlighted,
+ ]);
+
+ // Push combined signatures
+ finalChildren.push(combinedSigNode);
+
+ // Inject properties needed by CodeTabs component
+ const count = activeOverloadGroup.signatures.length;
+
+ const languagesArr = [];
+ const displayNamesArr = [];
+
+ for (let i = 0; i < count; i++) {
+ languagesArr.push('overload');
+ displayNamesArr.push(`Overload #${i + 1}`);
+ }
+
+ activeOverloadGroup.tabsNode.attributes.push(
+ createAttributeNode('languages', languagesArr.join('|')),
+ createAttributeNode('displayNames', displayNamesArr.join('|'))
+ );
+
+ // Push the tabs
+ finalChildren.push(activeOverloadGroup.tabsNode);
+
+ activeOverloadGroup = null;
+ };
+
+ /**
+ * Processes a single API entry node belonging to an overload group.
+ * It extracts its signature and pushes its remaining content into a new tab panel.
+ * @param {import('estree').Node} node - The AST node to process and add to the active group.
+ */
+ const processOverloadNode = node => {
+ const sigRaw = extractSignature(node);
+ if (sigRaw) {
+ activeOverloadGroup.signatures.push(sigRaw);
+ }
+ activeOverloadGroup.tabsNode.children.push(wrapInDiv(node));
+ };
+
+ for (const [i, current] of processedChildren.entries()) {
+ if (originalEntries[i].heading?.data?.isOverload) {
+ if (activeOverloadGroup) {
+ current.children.shift();
+ processOverloadNode(current);
+ } else {
+ const last = finalChildren.pop();
+ activeOverloadGroup = {
+ firstHeading: last?.children?.shift?.(),
+ signatures: [],
+ tabsNode: createJSXElement(JSX_IMPORTS.CodeTabs.name, {
+ inline: false,
+ children: [],
+ }),
+ };
+ current.children.shift();
+
+ processOverloadNode(last);
+ processOverloadNode(current);
+
+ if (activeOverloadGroup.firstHeading) {
+ finalChildren.push(activeOverloadGroup.firstHeading);
+ }
+ }
+ } else {
+ pushOverloadGroup();
+ finalChildren.push(current);
+ }
+ }
+
+ pushOverloadGroup();
+
+ return finalChildren;
+};
+
/**
* Builds the overall document layout tree
* @param {Array} entries - API documentation metadata entries
@@ -336,7 +471,7 @@ export const createDocumentLayout = async (entries, metadata) => {
readingTime: showReadingTime
? await readingTime(extractTextContent(entries))
: undefined,
- children: entries.map(processEntry),
+ children: groupOverloadsIntoTabs(entries.map(processEntry), entries),
}),
]);
};
diff --git a/packages/react/src/jsx-ast/utils/signature.mjs b/packages/react/src/jsx-ast/utils/signature.mjs
index d7032f32..47d5d580 100644
--- a/packages/react/src/jsx-ast/utils/signature.mjs
+++ b/packages/react/src/jsx-ast/utils/signature.mjs
@@ -67,7 +67,9 @@ export const createSignatureCodeBlock = (functionName, signature, heading) => {
const sig = generateSignature(functionName, signature, heading);
const highlighted = highlighter.highlightToHast(sig, 'typescript');
- return createElement('div', { class: 'signature' }, [highlighted]);
+ return createElement('div', { class: 'signature', dataSignatureRaw: sig }, [
+ highlighted,
+ ]);
};
/**