From 48a8e0c4d8a70ffd0e5a6d5b90a048ee92567174 Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Thu, 20 Aug 2026 00:00:17 +0300 Subject: [PATCH 1/3] feat(ui): group overloaded functions into tabs --- packages/react/package.json | 1 + packages/react/src/html/constants.mjs | 4 ++ .../html/ui/components/OverloadTabs/index.jsx | 34 +++++++++++ .../components/OverloadTabs/index.module.css | 21 +++++++ packages/react/src/html/ui/index.css | 6 ++ .../react/src/jsx-ast/utils/buildContent.mjs | 58 ++++++++++++++++++- pnpm-lock.yaml | 3 + 7 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/html/ui/components/OverloadTabs/index.jsx create mode 100644 packages/react/src/html/ui/components/OverloadTabs/index.module.css diff --git a/packages/react/package.json b/packages/react/package.json index 1e7d63ff8..2ac85a1fa 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -32,6 +32,7 @@ "@fontsource-variable/open-sans": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0", "@heroicons/react": "^2.2.0", + "@radix-ui/react-tabs": "^1.1.0", "@doc-kit/core": "workspace:*", "@node-core/rehype-shiki": "^1.4.3", "@node-core/ui-components": "^1.7.4", diff --git a/packages/react/src/html/constants.mjs b/packages/react/src/html/constants.mjs index 4ca3e783a..e3fe3561e 100644 --- a/packages/react/src/html/constants.mjs +++ b/packages/react/src/html/constants.mjs @@ -26,6 +26,10 @@ export const JSX_IMPORTS = { name: 'CodeTabs', source: resolve(ROOT, './ui/components/CodeTabs'), }, + OverloadTabs: { + name: 'OverloadTabs', + source: resolve(ROOT, './ui/components/OverloadTabs'), + }, MDXTooltip: { name: 'MDXTooltip', isDefaultExport: false, diff --git a/packages/react/src/html/ui/components/OverloadTabs/index.jsx b/packages/react/src/html/ui/components/OverloadTabs/index.jsx new file mode 100644 index 000000000..0012e565c --- /dev/null +++ b/packages/react/src/html/ui/components/OverloadTabs/index.jsx @@ -0,0 +1,34 @@ +import Tabs from '@node-core/ui-components/Common/Tabs'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; + +import styles from './index.module.css'; +import withIsland from '../../islands/withIsland.jsx'; + +const OverloadTabs = ({ children }) => { + const tabs = children.map((_, index) => ({ + key: `${index + 1}`, + label: `${index + 1}`, + })); + + return ( + +
+ {children.map((child, index) => ( + + {child} + + ))} +
+
+ ); +}; + +export default withIsland(OverloadTabs, { + name: 'OverloadTabs', + on: { interaction: 'pointerover,focusin,touchstart' }, +}); diff --git a/packages/react/src/html/ui/components/OverloadTabs/index.module.css b/packages/react/src/html/ui/components/OverloadTabs/index.module.css new file mode 100644 index 000000000..80baaf7ef --- /dev/null +++ b/packages/react/src/html/ui/components/OverloadTabs/index.module.css @@ -0,0 +1,21 @@ +.panelContainer { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} + +.panel { + grid-column: 1; + grid-row: 1; + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: opacity 0.2s ease; + margin-top: calc(var(--spacing, 0.25rem) * 2); +} + +.panel[data-state="inactive"] { + opacity: 0; + visibility: hidden; + pointer-events: none; +} diff --git a/packages/react/src/html/ui/index.css b/packages/react/src/html/ui/index.css index e385a3eb2..7d97506de 100644 --- a/packages/react/src/html/ui/index.css +++ b/packages/react/src/html/ui/index.css @@ -78,6 +78,12 @@ main { } } + .overload-panel { + display: flex; + flex-direction: column; + gap: calc(var(--spacing) * 6); + } + table { td { word-break: break-all; diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs index 7b9a7dda4..ac1cd1451 100644 --- a/packages/react/src/jsx-ast/utils/buildContent.mjs +++ b/packages/react/src/jsx-ast/utils/buildContent.mjs @@ -310,6 +310,62 @@ 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 = []; + + /** + * Wraps the AST children of a function entry in a standard panel div. + * @param {import('estree').Node} rootNode - The AST node representing the function content + * @returns {import('estree').Node} A new div JSX element AST node containing the children + */ + const wrapInDiv = rootNode => { + return createJSXElement('div', { + inline: false, + className: 'overload-panel', + children: rootNode.children || [], + }); + }; + + for (const [i, current] of processedChildren.entries()) { + if (originalEntries[i].heading?.data?.isOverload) { + const last = finalChildren.pop(); + + if (last && last.name === JSX_IMPORTS.OverloadTabs.name) { + current.children.shift(); + last.children.push(wrapInDiv(current)); + finalChildren.push(last); + } else { + const firstHeading = last.children.shift(); + current.children.shift(); + + finalChildren.push(firstHeading); + finalChildren.push({ + type: 'heading', + depth: (firstHeading.depth || 2) + 1, + children: [{ type: 'text', value: 'Overloads' }], + }); + + finalChildren.push( + createJSXElement(JSX_IMPORTS.OverloadTabs.name, { + inline: false, + children: [wrapInDiv(last), wrapInDiv(current)], + }) + ); + } + } else { + finalChildren.push(current); + } + } + + return finalChildren; +}; + /** * Builds the overall document layout tree * @param {Array} entries - API documentation metadata entries @@ -325,7 +381,7 @@ export const createDocumentLayout = (entries, metadata) => { metadata, headings: extractHeadings(entries), readingTime: readingTime(extractTextContent(entries)).text, - children: entries.map(processEntry), + children: groupOverloadsIntoTabs(entries.map(processEntry), entries), }), ]); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c6191fc3..8b2e1d525 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,9 @@ importers: '@orama/ui': specifier: ^1.5.4 version: 1.5.4(@orama/core@1.2.19)(@types/react@19.2.18)(react@19.2.8)(supports-color@7.2.0) + '@radix-ui/react-tabs': + specifier: ^1.1.0 + version: 1.1.21(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) estree-util-to-js: specifier: ^2.0.0 version: 2.0.0 From 537808f63beb3ef8003642e2ced404468cd4e379 Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Thu, 20 Aug 2026 00:34:43 +0300 Subject: [PATCH 2/3] test(jsx-ast): add test to increase coverage of handling overloads --- .../html/ui/components/OverloadTabs/index.jsx | 1 + .../components/OverloadTabs/index.module.css | 2 +- .../utils/__tests__/buildContent.test.mjs | 71 ++++++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/react/src/html/ui/components/OverloadTabs/index.jsx b/packages/react/src/html/ui/components/OverloadTabs/index.jsx index 0012e565c..b4449b358 100644 --- a/packages/react/src/html/ui/components/OverloadTabs/index.jsx +++ b/packages/react/src/html/ui/components/OverloadTabs/index.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react-x/no-array-index-key */ import Tabs from '@node-core/ui-components/Common/Tabs'; import * as TabsPrimitive from '@radix-ui/react-tabs'; diff --git a/packages/react/src/html/ui/components/OverloadTabs/index.module.css b/packages/react/src/html/ui/components/OverloadTabs/index.module.css index 80baaf7ef..b23fe518c 100644 --- a/packages/react/src/html/ui/components/OverloadTabs/index.module.css +++ b/packages/react/src/html/ui/components/OverloadTabs/index.module.css @@ -14,7 +14,7 @@ margin-top: calc(var(--spacing, 0.25rem) * 2); } -.panel[data-state="inactive"] { +.panel[data-state='inactive'] { opacity: 0; visibility: hidden; pointer-events: none; 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 225f8b857..2a5ac9da5 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', @@ -142,3 +146,68 @@ 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 makeNode = (className, bodyText) => ({ + type: 'element', + tagName: 'div', + properties: { className }, + children: [ + { type: 'element', tagName: 'h3', depth: 3 }, // The heading to be stripped + { type: 'text', value: bodyText }, + ], + }); + + const processedChildren = [ + makeNode('entry-a', 'body a'), + makeNode('entry-b1', 'body b1'), + makeNode('entry-b2', 'body b2'), + makeNode('entry-b3', 'body b3'), + makeNode('entry-c', 'body c'), + ]; + + const result = groupOverloadsIntoTabs(processedChildren, originalEntries); + + // 0: funcA, 1: funcB-heading, 2: Overloads-heading, 3: OverloadTabs(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 "Overloads" heading + assert.equal(result[2].children[0].value, 'Overloads'); + + // Fourth element is the OverloadTabs component + const tabsComponent = result[3]; + assert.equal(tabsComponent.name, 'OverloadTabs'); + 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'); + assert.equal(panel1.children[0].type, 'text'); + assert.equal(panel1.children[0].value, 'body b1'); + + 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'); + + // Fifth element is untouched + assert.equal(result[4].properties.className, 'entry-c'); + }); +}); From abdccbdf3884d547a0616aa8696c97797f8ba1da Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Sun, 23 Aug 2026 10:22:20 +0300 Subject: [PATCH 3/3] feat(ui): reimplement the tabbed UI for overloaded functions --- .../html/ui/components/OverloadTabs/index.jsx | 2 +- .../utils/__tests__/buildContent.test.mjs | 67 ++++++++---- .../react/src/jsx-ast/utils/buildContent.mjs | 102 ++++++++++++++---- .../react/src/jsx-ast/utils/signature.mjs | 4 +- 4 files changed, 137 insertions(+), 38 deletions(-) diff --git a/packages/react/src/html/ui/components/OverloadTabs/index.jsx b/packages/react/src/html/ui/components/OverloadTabs/index.jsx index b4449b358..dbe70e896 100644 --- a/packages/react/src/html/ui/components/OverloadTabs/index.jsx +++ b/packages/react/src/html/ui/components/OverloadTabs/index.jsx @@ -8,7 +8,7 @@ import withIsland from '../../islands/withIsland.jsx'; const OverloadTabs = ({ children }) => { const tabs = children.map((_, index) => ({ key: `${index + 1}`, - label: `${index + 1}`, + label: `Overload #${index + 1}`, })); return ( 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 2a5ac9da5..894fd8bbd 100644 --- a/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs +++ b/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs @@ -157,28 +157,47 @@ describe('groupOverloadsIntoTabs', () => { { heading: { data: { name: 'funcC', isOverload: false } } }, ]; - const makeNode = (className, bodyText) => ({ - type: 'element', - tagName: 'div', - properties: { className }, - children: [ + 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'), - makeNode('entry-b2', 'body b2'), - makeNode('entry-b3', 'body b3'), + 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: Overloads-heading, 3: OverloadTabs(funcB), 4: funcC - assert.equal(result.length, 5); + // 0: funcA, 1: funcB-heading, 2: Overloads-heading, 3: CombinedSignatures, 4: OverloadTabs(funcB), 5: funcC + assert.equal(result.length, 6); // First element is untouched assert.equal(result[0].properties.className, 'entry-a'); @@ -189,8 +208,21 @@ describe('groupOverloadsIntoTabs', () => { // Third element is the "Overloads" heading assert.equal(result[2].children[0].value, 'Overloads'); - // Fourth element is the OverloadTabs component - const tabsComponent = result[3]; + // Fourth element is the combined signatures block + const combinedSigBlock = result[3]; + assert.deepEqual(combinedSigBlock.properties.className, ['signature']); + + // Assert that the combined signatures contain the formatted 'Overload #X' text + const combinedText = getText(combinedSigBlock); + assert.match(combinedText, /Overload #1/); + assert.match(combinedText, /function funcB\(arg1\);/); + assert.match(combinedText, /Overload #2/); + assert.match(combinedText, /function funcB\(arg1, arg2\);/); + assert.match(combinedText, /Overload #3/); + assert.match(combinedText, /function funcB\(arg1, arg2, arg3\);/); + + // Fifth element is the OverloadTabs component + const tabsComponent = result[4]; assert.equal(tabsComponent.name, 'OverloadTabs'); assert.equal(tabsComponent.children.length, 3); // 3 tab panels @@ -198,16 +230,15 @@ describe('groupOverloadsIntoTabs', () => { const panel1 = tabsComponent.children[0]; const classAttr1 = panel1.attributes.find(a => a.name === 'className'); assert.equal(classAttr1.value, 'overload-panel'); - assert.equal(panel1.children[0].type, 'text'); + + // Second panel child should be the text we inserted assert.equal(panel1.children[0].value, 'body b1'); + assert.equal(result[5].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'); - - // Fifth element is untouched - assert.equal(result[4].properties.className, 'entry-c'); }); }); diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs index ac1cd1451..fb207a4ec 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 { omitKeys } from '@doc-kit/core/utils/misc.mjs'; import { UNIST } from '@doc-kit/core/utils/queries/index.mjs'; import { transformNodesToString } from '@doc-kit/core/utils/unist.mjs'; @@ -318,11 +319,12 @@ export const processEntry = entry => { */ export const groupOverloadsIntoTabs = (processedChildren, originalEntries) => { const finalChildren = []; + let activeOverloadGroup = null; /** - * Wraps the AST children of a function entry in a standard panel div. - * @param {import('estree').Node} rootNode - The AST node representing the function content - * @returns {import('estree').Node} A new div JSX element AST node containing the children + * 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', { @@ -332,37 +334,101 @@ export const groupOverloadsIntoTabs = (processedChildren, originalEntries) => { }); }; + /** + * 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; + } + + // Build the combined signature raw string + const combinedSigRaw = activeOverloadGroup.signatures + .map((sig, idx) => `// Overload #${idx + 1}\n${sig}`) + .join('\n\n'); + + const highlighted = highlighter.highlightToHast( + combinedSigRaw, + 'typescript' + ); + const combinedSigNode = createElement('div', { class: 'signature' }, [ + highlighted, + ]); + + // Push combined signatures + finalChildren.push(combinedSigNode); + // 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) { - const last = finalChildren.pop(); - - if (last && last.name === JSX_IMPORTS.OverloadTabs.name) { + if (activeOverloadGroup) { current.children.shift(); - last.children.push(wrapInDiv(current)); - finalChildren.push(last); + processOverloadNode(current); } else { - const firstHeading = last.children.shift(); + const last = finalChildren.pop(); + activeOverloadGroup = { + firstHeading: last.children.shift(), + signatures: [], + tabsNode: createJSXElement(JSX_IMPORTS.OverloadTabs.name, { + inline: false, + children: [], + }), + }; current.children.shift(); - finalChildren.push(firstHeading); + processOverloadNode(last); + processOverloadNode(current); + + finalChildren.push(activeOverloadGroup.firstHeading); finalChildren.push({ type: 'heading', - depth: (firstHeading.depth || 2) + 1, + depth: (activeOverloadGroup.firstHeading.depth || 2) + 1, children: [{ type: 'text', value: 'Overloads' }], }); - - finalChildren.push( - createJSXElement(JSX_IMPORTS.OverloadTabs.name, { - inline: false, - children: [wrapInDiv(last), wrapInDiv(current)], - }) - ); } } else { + pushOverloadGroup(); finalChildren.push(current); } } + pushOverloadGroup(); + return finalChildren; }; diff --git a/packages/react/src/jsx-ast/utils/signature.mjs b/packages/react/src/jsx-ast/utils/signature.mjs index d7032f326..47d5d5808 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, + ]); }; /**