From f70849d7d7ca7683e32b296e012c7791e95ed77a Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 22 Jun 2026 09:21:13 +0200 Subject: [PATCH 1/2] fix(react-tree-grid): handle wrapped rows in navigation --- .../ThreadedVirtualizedNavigation.fixture.tsx | 32 +++++---- .../VirtualizedMeetingsNavigation.fixture.tsx | 42 ++++++------ .../hooks/useTreeGridLevelNavigation.test.ts | 52 ++++++++++++-- .../src/hooks/useTreeGridLevelNavigation.ts | 67 ++++++++++++------- 4 files changed, 132 insertions(+), 61 deletions(-) diff --git a/packages/react-tree-grid/src/components/TreeGrid/__tests__/fixtures/ThreadedVirtualizedNavigation.fixture.tsx b/packages/react-tree-grid/src/components/TreeGrid/__tests__/fixtures/ThreadedVirtualizedNavigation.fixture.tsx index 5f71552d..fd24d526 100644 --- a/packages/react-tree-grid/src/components/TreeGrid/__tests__/fixtures/ThreadedVirtualizedNavigation.fixture.tsx +++ b/packages/react-tree-grid/src/components/TreeGrid/__tests__/fixtures/ThreadedVirtualizedNavigation.fixture.tsx @@ -63,24 +63,28 @@ const ThreadedVirtualizedRow = ({ if (item.type === 'header') { return ( - - {item.value} - Header - +
+ + {item.value} + Header + +
); } return ( - - {item.value} - {item.type} - +
+ + {item.value} + {item.type} + +
); }; diff --git a/packages/react-tree-grid/src/components/TreeGrid/__tests__/fixtures/VirtualizedMeetingsNavigation.fixture.tsx b/packages/react-tree-grid/src/components/TreeGrid/__tests__/fixtures/VirtualizedMeetingsNavigation.fixture.tsx index 1be331d9..5ee44332 100644 --- a/packages/react-tree-grid/src/components/TreeGrid/__tests__/fixtures/VirtualizedMeetingsNavigation.fixture.tsx +++ b/packages/react-tree-grid/src/components/TreeGrid/__tests__/fixtures/VirtualizedMeetingsNavigation.fixture.tsx @@ -58,30 +58,34 @@ const VirtualizedMeetingsRow = ({ if (item.type === 'section') { return ( +
+ + {item.value} + Section + +
+ ); + } + + return ( +
- {item.value} - Section + + + + Meeting - ); - } - - return ( - - - - - Meeting - +
); }; diff --git a/packages/react-tree-grid/src/hooks/useTreeGridLevelNavigation.test.ts b/packages/react-tree-grid/src/hooks/useTreeGridLevelNavigation.test.ts index f669ce1f..d58bf8bf 100644 --- a/packages/react-tree-grid/src/hooks/useTreeGridLevelNavigation.test.ts +++ b/packages/react-tree-grid/src/hooks/useTreeGridLevelNavigation.test.ts @@ -14,6 +14,31 @@ const createRow = (level: number): HTMLDivElement => { return row; }; +const createWrappedRow = ( + level: number, + treeGrid: HTMLElement +): HTMLDivElement => { + const wrapper = document.createElement('div'); + const row = document.createElement('div'); + + row.setAttribute('role', 'row'); + row.setAttribute('aria-level', String(level)); + row.tabIndex = 0; + row.scrollIntoView = jest.fn(); + + wrapper.appendChild(row); + treeGrid.appendChild(wrapper); + + return row; +}; + +const createTreeGrid = (): HTMLDivElement => { + const treeGrid = document.createElement('div'); + treeGrid.setAttribute('role', 'treegrid'); + document.body.appendChild(treeGrid); + return treeGrid; +}; + const createKeyboardEvent = ( key: string, target: HTMLElement @@ -62,9 +87,10 @@ describe('useTreeGridLevelNavigation', () => { it('focuses the next row at the same level', () => { const { result } = renderHook(() => useTreeGridLevelNavigation()); - const currentRow = createRow(2); - createRow(3); - const nextRow = createRow(2); + const treeGrid = createTreeGrid(); + const currentRow = createWrappedRow(2, treeGrid); + createWrappedRow(3, treeGrid); + const nextRow = createWrappedRow(2, treeGrid); const nativeEvent = createKeyboardEvent(ArrowDown, currentRow); const keyboardEvent = createReactKeyboardEvent(nativeEvent); @@ -77,8 +103,9 @@ describe('useTreeGridLevelNavigation', () => { it('allows shallower-boundary navigation to fall through', () => { const { result } = renderHook(() => useTreeGridLevelNavigation()); - const parentRow = createRow(1); - const currentRow = createRow(2); + const treeGrid = createTreeGrid(); + const parentRow = createWrappedRow(1, treeGrid); + const currentRow = createWrappedRow(2, treeGrid); const nativeEvent = createKeyboardEvent(ArrowUp, currentRow); const keyboardEvent = createReactKeyboardEvent(nativeEvent); const moveFocusEvent = createMoveFocusEvent(nativeEvent, parentRow); @@ -89,4 +116,19 @@ describe('useTreeGridLevelNavigation', () => { expect(moveFocusEvent.defaultPrevented).toBe(false); expect(keyboardEvent.preventDefault).not.toHaveBeenCalled(); }); + + it('skips wrapper elements when finding adjacent rows', () => { + const { result } = renderHook(() => useTreeGridLevelNavigation()); + const treeGrid = createTreeGrid(); + const currentRow = createWrappedRow(1, treeGrid); + const nextRow = createWrappedRow(1, treeGrid); + const nativeEvent = createKeyboardEvent(ArrowDown, currentRow); + const keyboardEvent = createReactKeyboardEvent(nativeEvent); + + result.current.onKeyDown(keyboardEvent); + + expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(1); + expect(nextRow.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }); + expect(nextRow).toBe(document.activeElement); + }); }); diff --git a/packages/react-tree-grid/src/hooks/useTreeGridLevelNavigation.ts b/packages/react-tree-grid/src/hooks/useTreeGridLevelNavigation.ts index 7ef8e0c5..4cf8b2de 100644 --- a/packages/react-tree-grid/src/hooks/useTreeGridLevelNavigation.ts +++ b/packages/react-tree-grid/src/hooks/useTreeGridLevelNavigation.ts @@ -31,32 +31,53 @@ const findAdjacentRowAtLevel = ( return { type: 'none' }; } - let sibling: Element | null = - direction === ArrowDown - ? row.nextElementSibling - : row.previousElementSibling; - - while (sibling) { - if (isHTMLElement(sibling) && sibling.getAttribute('role') === 'row') { - const siblingLevel = Number(sibling.getAttribute('aria-level')); - - if (Number.isNaN(siblingLevel)) { - return { type: 'none' }; - } - - if (siblingLevel < level) { - return { type: 'shallower-boundary' }; - } - - if (siblingLevel === level) { - return { type: 'same-level', row: sibling }; - } + const treeGrid = row.closest('[role="treegrid"]'); + if (!treeGrid) { + return { type: 'none' }; + } + + const nodeFilter = row.ownerDocument.defaultView?.NodeFilter; + if (!nodeFilter) { + return { type: 'none' }; + } + + const rowWalker = row.ownerDocument.createTreeWalker( + treeGrid, + nodeFilter.SHOW_ELEMENT, + { + acceptNode: (node) => + isHTMLElement(node) && node.getAttribute('role') === 'row' + ? nodeFilter.FILTER_ACCEPT + : nodeFilter.FILTER_SKIP, } + ); + + rowWalker.currentNode = row; + for ( + let sibling = + direction === ArrowDown ? rowWalker.nextNode() : rowWalker.previousNode(); + sibling; sibling = - direction === ArrowDown - ? sibling.nextElementSibling - : sibling.previousElementSibling; + direction === ArrowDown ? rowWalker.nextNode() : rowWalker.previousNode() + ) { + if (!isHTMLElement(sibling)) { + continue; + } + + const siblingLevel = Number(sibling.getAttribute('aria-level')); + + if (Number.isNaN(siblingLevel)) { + return { type: 'none' }; + } + + if (siblingLevel < level) { + return { type: 'shallower-boundary' }; + } + + if (siblingLevel === level) { + return { type: 'same-level', row: sibling }; + } } return { type: 'none' }; From 518103e05d35e337b0bc5ca882663dbff2e6fdde Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Mon, 22 Jun 2026 09:22:02 +0200 Subject: [PATCH 2/2] chore(react-tree-grid): add changeset for navigation fix --- ...act-tree-grid-4d3116d4-4a33-4a66-988d-62f9180d18f1.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-contrib-react-tree-grid-4d3116d4-4a33-4a66-988d-62f9180d18f1.json diff --git a/change/@fluentui-contrib-react-tree-grid-4d3116d4-4a33-4a66-988d-62f9180d18f1.json b/change/@fluentui-contrib-react-tree-grid-4d3116d4-4a33-4a66-988d-62f9180d18f1.json new file mode 100644 index 00000000..1f4d1a7c --- /dev/null +++ b/change/@fluentui-contrib-react-tree-grid-4d3116d4-4a33-4a66-988d-62f9180d18f1.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Fix TreeGrid level navigation when virtualized rows are wrapped in additional containers", + "packageName": "@fluentui-contrib/react-tree-grid", + "email": "bsunderhus@microsoft.com", + "dependentChangeType": "patch" +}