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"
+}
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' };