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",