diff --git a/packages/elements-core/package.json b/packages/elements-core/package.json
index f22c54710..ff4787e37 100644
--- a/packages/elements-core/package.json
+++ b/packages/elements-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@stoplight/elements-core",
- "version": "9.0.16",
+ "version": "9.0.17",
"sideEffects": [
"web-components.min.js",
"src/web-components/**",
diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx
index f886c9f9d..c174efcef 100644
--- a/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx
+++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx
@@ -1,4 +1,4 @@
-import { screen } from '@testing-library/dom';
+import { fireEvent, screen } from '@testing-library/dom';
import { render } from '@testing-library/react';
import * as React from 'react';
@@ -313,4 +313,387 @@ describe('utils', () => {
});
});
});
+ describe('TableOfContents - Dividers, Links, Nodes, Groups, and Nesting', () => {
+ describe('Divider Component', () => {
+ it('should render divider with title', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.getByText('Section Title')).toBeInTheDocument();
+ unmount();
+ });
+
+ it('should render divider with responsive mode styling', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.getByText('Section Title')).toBeInTheDocument();
+ unmount();
+ });
+ });
+
+ describe('External Links', () => {
+ it('should render external link with title', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.getByText('External Link')).toBeInTheDocument();
+ unmount();
+ });
+ });
+
+ describe('Node Component', () => {
+ it('should render node with meta information', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.queryByText('Test Node')).toBeInTheDocument();
+ expect(screen.queryByText('get')).toBeInTheDocument();
+ unmount();
+ });
+
+ it('should handle node click and set active state', () => {
+ const onLinkClick = jest.fn();
+ const { unmount } = render(
+ ,
+ );
+
+ const node = screen.queryByText('Test Node');
+ node?.click();
+ expect(onLinkClick).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should not trigger link click when node is already active', () => {
+ const onLinkClick = jest.fn();
+ const { unmount } = render(
+ ,
+ );
+
+ const node = screen.queryByText('Test Node');
+ node?.click();
+ expect(onLinkClick).not.toHaveBeenCalled();
+ unmount();
+ });
+ });
+
+ describe('Group Expansion', () => {
+ it('should expand group when clicking chevron icon', () => {
+ const { unmount, container } = render(
+ ,
+ );
+
+ expect(screen.queryByTitle(/Test Item/)).not.toBeInTheDocument();
+
+ const chevronIcon = container.querySelector('[data-icon="chevron-right"]') as HTMLElement;
+ fireEvent.click(chevronIcon);
+
+ expect(screen.queryByTitle(/Test Item/)).toBeInTheDocument();
+ unmount();
+ });
+
+ it('should collapse group when clicking chevron icon on open group', () => {
+ const { unmount, container } = render(
+ ,
+ );
+
+ expect(screen.queryByTitle(/Test Item/)).toBeInTheDocument();
+
+ const chevronIcon = container.querySelector('[data-icon="chevron-down"]') as HTMLElement;
+ fireEvent.click(chevronIcon);
+
+ expect(screen.queryByTitle(/Test Item/)).not.toBeInTheDocument();
+ unmount();
+ });
+ });
+
+ describe('Version Badge', () => {
+ it('should not display version for http_operation type', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.queryByText(/v1.0.0/)).not.toBeInTheDocument();
+ unmount();
+ });
+
+ it('should display version for non-http_operation types with meta', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.queryByText(/v2.1.0/)).toBeInTheDocument();
+ unmount();
+ });
+ });
+
+ describe('Responsive Mode', () => {
+ it('should apply responsive styling when isInResponsiveMode is true', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.queryByTitle(/Test Item/)).toBeInTheDocument();
+ unmount();
+ });
+ });
+
+ describe('Deep Nesting', () => {
+ it('should handle deeply nested groups', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.queryByTitle(/Level 1/)).toBeInTheDocument();
+ expect(screen.queryByTitle(/Level 2/)).toBeInTheDocument();
+ expect(screen.queryByTitle(/Level 3/)).toBeInTheDocument();
+ expect(screen.queryByTitle(/Deep Item/)).toBeInTheDocument();
+ unmount();
+ });
+ });
+
+ describe('Error Cases', () => {
+ it('should handle empty tree', () => {
+ const { unmount } = render();
+
+ expect(screen.queryByTitle(/Root/)).not.toBeInTheDocument();
+ unmount();
+ });
+
+ it('should handle invalid item types', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.queryByTitle(/Invalid Item/)).not.toBeInTheDocument();
+ unmount();
+ });
+ });
+ });
});
diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx
index 14415a517..35fb2846e 100644
--- a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx
+++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx
@@ -84,41 +84,47 @@ export const TableOfContents = React.memo(
}, []);
const updatedTree = updateTocTree(tree, '');
- const findFirstMatchAndIndexMatch = React.useCallback(
- (items: TableOfContentsGroupItem[], id: string | undefined): [TableOfContentsGroupItem | undefined, boolean] => {
- let firstMatch: TableOfContentsGroupItem | undefined;
- let hasAnyLastIndexMatch = false;
- if (!id) return [firstMatch, hasAnyLastIndexMatch];
-
- const walk = (arr: TableOfContentsGroupItem[], stack: TableOfContentsGroupItem[]): boolean => {
- for (const itm of arr) {
- const newStack = stack.concat(itm);
-
- const matches = ('slug' in itm && (itm as any).slug === id) || ('id' in itm && (itm as any).id === id);
- if (matches) {
- if (!firstMatch) firstMatch = itm;
- const hasLastIndexMatch = newStack.some(el => 'index' in el && (el as any).index === lastActiveIndex);
- if (hasLastIndexMatch) hasAnyLastIndexMatch = true;
+ const findMatchingItems = (
+ updateTree: TableOfContentsGroupItem[],
+ activeId: string,
+ lastActiveIndex: string,
+ ): [TableOfContentsGroupItem | undefined, boolean] => {
+ let exactMatch: TableOfContentsGroupItem | undefined; // matches activeId + lastActiveIndex
+ let partialMatch: TableOfContentsGroupItem | undefined; // matches only activeId
+
+ const searchInChildren = (items: TableOfContentsGroupItem[]) => {
+ for (const item of items) {
+ const hasSlug = 'slug' in item;
+ const hasId = 'id' in item;
+ const isIdMatch = (hasSlug && item.slug === activeId) || (hasId && item.id === activeId);
+
+ if (isIdMatch) {
+ const hasIndex = 'index' in item;
+ if (hasIndex && item.index === lastActiveIndex) {
+ exactMatch = exactMatch ?? item; // first exact match wins
+ } else {
+ partialMatch = partialMatch ?? item; // first partial match wins
}
+ }
- if ('items' in itm && Array.isArray((itm as any).items)) {
- if (walk((itm as any).items, newStack)) return true;
- }
+ const hasItems = 'items' in item;
+ if (hasItems && Array.isArray(item.items)) {
+ searchInChildren(item.items);
}
+ }
+ };
- return false;
- };
+ searchInChildren(updateTree);
- walk(items, []);
- return [firstMatch, hasAnyLastIndexMatch];
- },
- [lastActiveIndex],
- );
+ const hasExactMatch = exactMatch !== undefined;
+ const bestMatch = exactMatch ?? partialMatch; // prioritize exact match
- const [firstMatchItem, hasAnyLastIndexMatch] = React.useMemo(
- () => findFirstMatchAndIndexMatch(updatedTree, activeId),
- [updatedTree, activeId, findFirstMatchAndIndexMatch],
- );
+ return [bestMatch, hasExactMatch];
+ };
+
+ const [firstMatchItem, hasAnyLastIndexMatch] = React.useMemo(() => {
+ return findMatchingItems(updatedTree, activeId, lastActiveIndex);
+ }, [updatedTree, activeId, lastActiveIndex]);
React.useEffect(() => {
if (!hasAnyLastIndexMatch && firstMatchItem && 'index' in firstMatchItem && firstMatchItem.index) {
diff --git a/packages/elements-dev-portal/package.json b/packages/elements-dev-portal/package.json
index 53cc6e8b2..7f34fbae1 100644
--- a/packages/elements-dev-portal/package.json
+++ b/packages/elements-dev-portal/package.json
@@ -1,6 +1,6 @@
{
"name": "@stoplight/elements-dev-portal",
- "version": "3.0.16",
+ "version": "3.0.17",
"description": "UI components for composing beautiful developer documentation.",
"keywords": [],
"sideEffects": [
@@ -66,7 +66,7 @@
"dependencies": {
"@stoplight/markdown-viewer": "^5.7.1",
"@stoplight/mosaic": "^1.53.5",
- "@stoplight/elements-core": "~9.0.16",
+ "@stoplight/elements-core": "~9.0.17",
"@stoplight/path": "^1.3.2",
"@stoplight/types": "^14.0.0",
"classnames": "^2.2.6",
diff --git a/packages/elements/package.json b/packages/elements/package.json
index c8dfd5bd6..2f640b687 100644
--- a/packages/elements/package.json
+++ b/packages/elements/package.json
@@ -1,6 +1,6 @@
{
"name": "@stoplight/elements",
- "version": "9.0.16",
+ "version": "9.0.17",
"description": "UI components for composing beautiful developer documentation.",
"keywords": [],
"sideEffects": [
@@ -63,7 +63,7 @@
]
},
"dependencies": {
- "@stoplight/elements-core": "~9.0.16",
+ "@stoplight/elements-core": "~9.0.17",
"@stoplight/http-spec": "^7.1.0",
"@stoplight/json": "^3.18.1",
"@stoplight/mosaic": "^1.53.5",