diff --git a/.changeset/treeview-sibling-traversal.md b/.changeset/treeview-sibling-traversal.md new file mode 100644 index 00000000000..99d153f82a4 --- /dev/null +++ b/.changeset/treeview-sibling-traversal.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +perf(TreeView): replace O(n) TreeWalker with O(depth) sibling traversal diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx index 2252c9d339a..874d719ac57 100644 --- a/packages/react/src/TreeView/TreeView.test.tsx +++ b/packages/react/src/TreeView/TreeView.test.tsx @@ -103,6 +103,21 @@ describe('Markup', () => { expect(subtree).toBeNull() }) + it('does not render collapsed subtree children in the DOM', () => { + const {queryByRole} = renderWithTheme( + + + Parent + + Child + + + , + ) + + expect(queryByRole('treeitem', {name: 'Child'})).toBeNull() + }) + it('uses aria-current', () => { const {getByRole} = renderWithTheme( diff --git a/packages/react/src/TreeView/useRovingTabIndex.ts b/packages/react/src/TreeView/useRovingTabIndex.ts index 98a7dbbfa6b..861ae4b8f27 100644 --- a/packages/react/src/TreeView/useRovingTabIndex.ts +++ b/packages/react/src/TreeView/useRovingTabIndex.ts @@ -142,30 +142,109 @@ export function getElementState(element: HTMLElement): 'open' | 'closed' | 'end' } } +/** + * Find the next or previous visible treeitem using direct DOM traversal. + * + * PERFORMANCE: This is O(tree depth) instead of O(n) because it walks + * siblings and parent/child edges directly, rather than creating a TreeWalker + * that scans from the root to find the current element on every keystroke. + * + * NOTE: This relies on TreeView.SubTree unmounting its children when collapsed + * (returning null when !isExpanded). Because collapsed subtree children are + * never in the DOM, we can safely skip them by only entering children of nodes + * with aria-expanded="true". If SubTree ever changes to keep collapsed children + * mounted (e.g. via CSS display:none), this logic would need to add filtering + * for items inside collapsed parents. + */ export function getVisibleElement(element: HTMLElement, direction: 'next' | 'previous'): HTMLElement | undefined { - const root = element.closest('[role=tree]') + if (direction === 'next') { + return getNextVisibleElement(element) + } + return getPreviousVisibleElement(element) +} - if (!root) return +function getNextVisibleElement(element: HTMLElement): HTMLElement | undefined { + // If the current item is expanded, the next visible item is its first child + if (element.getAttribute('aria-expanded') === 'true') { + const firstChild = getFirstChildElement(element) + if (firstChild) return firstChild + } - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, node => { - if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_SKIP - return node.getAttribute('role') === 'treeitem' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP - }) + // Otherwise, walk up the tree looking for a next sibling + let current: HTMLElement | undefined = element + while (current) { + const next = getNextSiblingTreeItem(current) + if (next) return next + + // No next sibling at this level, try the parent's next sibling + current = getParentElement(current) + } + + return undefined +} - let current = walker.firstChild() +function getPreviousVisibleElement(element: HTMLElement): HTMLElement | undefined { + const prev = getPreviousSiblingTreeItem(element) - while (current !== element) { - current = walker.nextNode() + if (prev) { + // Navigate to the deepest last visible descendant of the previous sibling + return getDeepestLastDescendant(prev) } - let next = direction === 'next' ? walker.nextNode() : walker.previousNode() + // No previous sibling, the parent is the previous visible element + return getParentElement(element) +} + +/** + * Walk into expanded subtrees to find the deepest last visible descendant. + * For example, if the last sibling is an expanded directory whose last child + * is also an expanded directory, we drill all the way down. + */ +function getDeepestLastDescendant(element: HTMLElement): HTMLElement { + let current = element + while (current.getAttribute('aria-expanded') === 'true') { + const lastChild = getLastChildTreeItem(current) + if (!lastChild) break + current = lastChild + } + return current +} - // If next element is nested inside a collapsed subtree, continue iterating - while (next instanceof HTMLElement && next.parentElement?.closest('[role=treeitem][aria-expanded=false]')) { - next = direction === 'next' ? walker.nextNode() : walker.previousNode() +function getNextSiblingTreeItem(element: HTMLElement): HTMLElement | undefined { + let sibling = element.nextElementSibling + while (sibling) { + if (sibling instanceof HTMLElement && sibling.getAttribute('role') === 'treeitem') { + return sibling + } + sibling = sibling.nextElementSibling } + return undefined +} + +function getPreviousSiblingTreeItem(element: HTMLElement): HTMLElement | undefined { + let sibling = element.previousElementSibling + while (sibling) { + if (sibling instanceof HTMLElement && sibling.getAttribute('role') === 'treeitem') { + return sibling + } + sibling = sibling.previousElementSibling + } + return undefined +} - return next instanceof HTMLElement ? next : undefined +function getLastChildTreeItem(element: HTMLElement): HTMLElement | undefined { + // Find the [role=group] child (the subtree container), then get its last treeitem + for (let i = element.children.length - 1; i >= 0; i--) { + const child = element.children[i] + if (child instanceof HTMLElement && child.getAttribute('role') === 'group') { + let lastChild = child.lastElementChild + while (lastChild && !(lastChild instanceof HTMLElement && lastChild.getAttribute('role') === 'treeitem')) { + lastChild = lastChild.previousElementSibling + } + return lastChild instanceof HTMLElement ? lastChild : undefined + } + } + return undefined } export function getFirstChildElement(element: HTMLElement): HTMLElement | undefined {