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 3850a2a38..0c99fa5ba 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'), + }, DocumentationIndex: { name: 'DocumentationIndex', source: resolve(ROOT, './ui/components/DocumentationIndex'), 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..dbe70e896 --- /dev/null +++ b/packages/react/src/html/ui/components/OverloadTabs/index.jsx @@ -0,0 +1,35 @@ +/* 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'; + +import styles from './index.module.css'; +import withIsland from '../../islands/withIsland.jsx'; + +const OverloadTabs = ({ children }) => { + const tabs = children.map((_, index) => ({ + key: `${index + 1}`, + label: `Overload #${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..b23fe518c --- /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/__tests__/buildContent.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs index 225f8b857..894fd8bbd 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,99 @@ 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: 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'); + + // 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 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 + + // 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[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'); + }); +}); diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs index ec2a17904..8d920664c 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'; @@ -320,6 +321,127 @@ 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; + } + + // 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) { + if (activeOverloadGroup) { + current.children.shift(); + processOverloadNode(current); + } else { + const last = finalChildren.pop(); + activeOverloadGroup = { + firstHeading: last.children.shift(), + signatures: [], + tabsNode: createJSXElement(JSX_IMPORTS.OverloadTabs.name, { + inline: false, + children: [], + }), + }; + current.children.shift(); + + processOverloadNode(last); + processOverloadNode(current); + + finalChildren.push(activeOverloadGroup.firstHeading); + finalChildren.push({ + type: 'heading', + depth: (activeOverloadGroup.firstHeading.depth || 2) + 1, + children: [{ type: 'text', value: 'Overloads' }], + }); + } + } else { + pushOverloadGroup(); + finalChildren.push(current); + } + } + + pushOverloadGroup(); + + return finalChildren; +}; + /** * Builds the overall document layout tree * @param {Array} entries - API documentation metadata entries 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, + ]); }; /** 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