From 2a6a820c77085d880c4b670868411ad99c0636e7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 10 Mar 2026 14:02:52 -0700 Subject: [PATCH 1/5] fix: expandable table rows keyboard navigation during DnD this is specifcally handling the case where the user navigates upwards through nested row drop targets --- .../grid/src/GridKeyboardDelegate.ts | 24 +- .../collections/src/getChildNodes.ts | 25 ++- packages/react-aria-components/src/Table.tsx | 39 +++- .../test/Treeble.test.js | 211 ++++++++++++------ 4 files changed, 197 insertions(+), 102 deletions(-) diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index b1fceaf3cb3..e9201559175 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -206,8 +206,8 @@ export class GridKeyboardDelegate> implements Key if (this.isRow(item)) { let children = getChildNodes(item, this.collection); return (this.direction === 'rtl' - ? getLastItem(children)?.key - : getFirstItem(children)?.key) ?? null; + ? getLastItem(children, item => item.type === 'cell')?.key + : getFirstItem(children, item => item.type === 'cell')?.key) ?? null; } // If focus is on a cell, focus the next cell if any, @@ -219,8 +219,8 @@ export class GridKeyboardDelegate> implements Key } let children = getChildNodes(parent, this.collection); let next = (this.direction === 'rtl' - ? getNthItem(children, item.index - 1) - : getNthItem(children, item.index + 1)) ?? null; + ? getNthItem(children, item.index - 1, item => item.type === 'cell') + : getNthItem(children, item.index + 1, item => item.type === 'cell')) ?? null; if (next) { return next.key ?? null; @@ -246,8 +246,8 @@ export class GridKeyboardDelegate> implements Key if (this.isRow(item)) { let children = getChildNodes(item, this.collection); return (this.direction === 'rtl' - ? getFirstItem(children)?.key - : getLastItem(children)?.key) ?? null; + ? getFirstItem(children, item => item.type === 'cell')?.key + : getLastItem(children, item => item.type === 'cell')?.key) ?? null; } // If focus is on a cell, focus the previous cell if any, @@ -259,8 +259,8 @@ export class GridKeyboardDelegate> implements Key } let children = getChildNodes(parent, this.collection); let prev = (this.direction === 'rtl' - ? getNthItem(children, item.index + 1) - : getNthItem(children, item.index - 1)) ?? null; + ? getNthItem(children, item.index + 1, item => item.type === 'cell') + : getNthItem(children, item.index - 1, item => item.type === 'cell')) ?? null; if (prev) { return prev.key ?? null; @@ -292,7 +292,7 @@ export class GridKeyboardDelegate> implements Key if (!parent) { return null; } - return getFirstItem(getChildNodes(parent, this.collection))?.key ?? null; + return getFirstItem(getChildNodes(parent, this.collection), item => item.type === 'cell')?.key ?? null; } } @@ -305,7 +305,7 @@ export class GridKeyboardDelegate> implements Key if (!item) { return null; } - key = getFirstItem(getChildNodes(item, this.collection))?.key ?? null; + key = getFirstItem(getChildNodes(item, this.collection), item => item.type === 'cell')?.key ?? null; } // Otherwise, focus the row itself. @@ -329,7 +329,7 @@ export class GridKeyboardDelegate> implements Key return null; } let children = getChildNodes(parent, this.collection); - return getLastItem(children)?.key ?? null; + return getLastItem(children, item => item.type === 'cell')?.key ?? null; } } @@ -343,7 +343,7 @@ export class GridKeyboardDelegate> implements Key return null; } let children = getChildNodes(item, this.collection); - key = getLastItem(children)?.key ?? null; + key = getLastItem(children, item => item.type === 'cell')?.key ?? null; } // Otherwise, focus the row itself. diff --git a/packages/@react-stately/collections/src/getChildNodes.ts b/packages/@react-stately/collections/src/getChildNodes.ts index f81aa494634..d5f1ce5c0f7 100644 --- a/packages/@react-stately/collections/src/getChildNodes.ts +++ b/packages/@react-stately/collections/src/getChildNodes.ts @@ -22,11 +22,11 @@ export function getChildNodes(node: Node, collection: Collection>) return node.childNodes; } -export function getFirstItem(iterable: Iterable): T | undefined { - return getNthItem(iterable, 0); +export function getFirstItem(iterable: Iterable, pred?: (item: T) => boolean): T | undefined { + return getNthItem(iterable, 0, pred); } -export function getNthItem(iterable: Iterable, index: number): T | undefined { +export function getNthItem(iterable: Iterable, index: number, pred?: (item: T) => boolean): T | undefined { if (index < 0) { return undefined; } @@ -34,17 +34,28 @@ export function getNthItem(iterable: Iterable, index: number): T | undefin let i = 0; for (let item of iterable) { if (i === index) { - return item; + if (!pred || pred(item)) { + return item; + } + + return undefined; } - i++; + if (!pred || pred(item)) { + i++; + } } + + // should only reach here if the entire iterable didn't contain a nth item that matched the pred + return undefined; } -export function getLastItem(iterable: Iterable): T | undefined { +export function getLastItem(iterable: Iterable, pred?: (item: T) => boolean): T | undefined { let lastItem: T | undefined = undefined; for (let value of iterable) { - lastItem = value; + if (!pred || pred(value)) { + lastItem = value; + } } return lastItem; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 3e68b7bc624..1be0e8f4621 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -210,7 +210,7 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection | null; let node = parent?.firstChildKey != null ? self.getItem(parent.firstChildKey) as CollectionNode | null : null; while (node) { + // note that we are returning ALL children not just cells now aka including child rows. Keep this in mind when getting a row's children + // since previously we had assumed this would only be cells yield node as Node; node = node.nextKey != null ? self.getItem(node.nextKey) as CollectionNode | null : null; - - // Return only cells as children of rows (nested rows are flattened into the body). - if (parent?.type === 'item' && node?.type !== 'cell') { - break; - } } } }; @@ -287,6 +284,10 @@ class TableCollection extends BaseCollection implements ITableCollection extends CollectionNode { static readonly type = 'item'; filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): TableRowNode | null { - let cells = collection.getChildren(this.key); - for (let cell of cells) { + for (let cell of collection.getChildren(this.key)) { + if (cell.type !== 'cell') { + // todo: skip child rows? this keeps it in line with previous behavior but we'll want to consider allowing customizations for + // this (keep/skip child rows). Also applies to Tree https://github.com/orgs/adobe/projects/19/views/4?sliceBy%5Bvalue%5D=Autocomplete&pane=issue&itemId=164093367 + continue; + } if (filterFn(cell.textValue, cell)) { let clone = this.clone(); newCollection.addDescendants(clone, collection); @@ -1313,7 +1318,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); - let {isVirtualized, CollectionBranch} = useContext(CollectionRendererContext); + let {isVirtualized} = useContext(CollectionRendererContext); let {rowProps, expandButtonProps, ...states} = useTableRow( { node: item, @@ -1392,6 +1397,20 @@ export const Row = /*#__PURE__*/ createBranchComponent( } }); + let children = useCachedChildren({ + items: state.collection.getChildren!(item.key), + children: item => { + switch (item.type) { + // skip loader and child rows since these are flattened in the body and are rendered as siblings, just like in Tree + case 'loader': + case 'item': + return <>; + default: + return item.render!(item); + } + } + }); + let DOMProps = filterDOMProps(props as any, {global: true}); delete DOMProps.id; delete DOMProps.onClick; @@ -1444,7 +1463,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( }], [SelectionIndicatorContext, {isSelected: states.isSelected}] ]}> - + {children} diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.js index e84dd0e07af..7675c2261fb 100644 --- a/packages/react-aria-components/test/Treeble.test.js +++ b/packages/react-aria-components/test/Treeble.test.js @@ -20,7 +20,7 @@ export function Cell(props) { return ( {composeRenderProps(props.children, (children, {hasChildItems, isTreeColumn}) => (<> - {isTreeColumn && hasChildItems && + {isTreeColumn && hasChildItems && } {children} @@ -93,6 +93,76 @@ function Example(props) { ); } +function ReorderableTreeble(props) { + let tree = useTreeData({ + initialItems: [ + {id: '1', title: 'Documents', type: 'Directory', date: '10/20/2025', children: [ + {id: '2', title: 'Project', type: 'Directory', date: '8/2/2025', children: [ + {id: '3', title: 'Weekly Report', type: 'File', date: '7/10/2025', children: []}, + {id: '4', title: 'Budget', type: 'File', date: '8/20/2025', children: []} + ]} + ]}, + {id: '5', title: 'Photos', type: 'Directory', date: '2/3/2026', children: [ + {id: '6', title: 'Image 1', type: 'File', date: '1/23/2026', children: []}, + {id: '7', title: 'Image 2', type: 'File', date: '2/3/2026', children: []} + ]} + ] + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys, items) => items.map(item => ({'text/plain': item.value.title})), + onMove(e) { + if (e.target.dropPosition === 'before') { + tree.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + tree.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + // Move items to become children of the target + let targetNode = tree.getItem(e.target.key); + if (targetNode) { + let targetIndex = targetNode.children ? targetNode.children.length : 0; + let keyArray = Array.from(e.keys); + for (let i = 0; i < keyArray.length; i++) { + tree.move(keyArray[i], e.target.key, targetIndex + i); + } + } + } + } + }); + + return ( + + + + Name + Type + Date Modified + + + {function renderItem(item) { + return ( + +
+ ); +} + describe('Treeble', () => { let utils = new User(); let user; @@ -105,7 +175,7 @@ describe('Treeble', () => { it('renders a treegrid', () => { let tree = render(); let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); - + expect(tester.table).toHaveAttribute('role', 'treegrid'); expect(tester.rows).toHaveLength(4); @@ -413,7 +483,7 @@ describe('Treeble', () => { await user.tab(); expect(document.activeElement).toBe(tester.rows[0]); expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'false'); - + await user.keyboard('{ArrowRight}'); expect(document.activeElement).toBe(tester.rows[0]); expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'true'); @@ -455,75 +525,6 @@ describe('Treeble', () => { }); it('should support drag and drop', async () => { - function ReorderableTreeble() { - let tree = useTreeData({ - initialItems: [ - {id: '1', title: 'Documents', type: 'Directory', date: '10/20/2025', children: [ - {id: '2', title: 'Project', type: 'Directory', date: '8/2/2025', children: [ - {id: '3', title: 'Weekly Report', type: 'File', date: '7/10/2025', children: []}, - {id: '4', title: 'Budget', type: 'File', date: '8/20/2025', children: []} - ]} - ]}, - {id: '5', title: 'Photos', type: 'Directory', date: '2/3/2026', children: [ - {id: '6', title: 'Image 1', type: 'File', date: '1/23/2026', children: []}, - {id: '7', title: 'Image 2', type: 'File', date: '2/3/2026', children: []} - ]} - ] - }); - - let {dragAndDropHooks} = useDragAndDrop({ - getItems: (keys, items) => items.map(item => ({'text/plain': item.value.title})), - onMove(e) { - if (e.target.dropPosition === 'before') { - tree.moveBefore(e.target.key, e.keys); - } else if (e.target.dropPosition === 'after') { - tree.moveAfter(e.target.key, e.keys); - } else if (e.target.dropPosition === 'on') { - // Move items to become children of the target - let targetNode = tree.getItem(e.target.key); - if (targetNode) { - let targetIndex = targetNode.children ? targetNode.children.length : 0; - let keyArray = Array.from(e.keys); - for (let i = 0; i < keyArray.length; i++) { - tree.move(keyArray[i], e.target.key, targetIndex + i); - } - } - } - } - }); - - return ( - - - - Name - Type - Date Modified - - - {function renderItem(item) { - return ( - -
- ); - } - let tree = render(); let tester = utils.createTester('Table', {root: tree.getByRole('treegrid')}); @@ -572,7 +573,7 @@ describe('Treeble', () => { 'Insert after Image 2', 'Insert after Photos' ]); - + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); @@ -584,4 +585,68 @@ describe('Treeble', () => { 'Image 1' ]); }); + + it('should properly walk through nested levels of drop positioning', async () => { + render(); + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Budget'); + + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Project'); + + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Documents and Photos'); + + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Photos'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Documents and Photos'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Project'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Budget'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Weekly Report and Budget'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Weekly Report'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Weekly Report'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Project'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Project'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Documents'); + + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Documents'); + }); }); From 777920cd44d9ef6e316c56d7a7c7bd95d1ddcf81 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 10 Mar 2026 14:56:57 -0700 Subject: [PATCH 2/5] update drop target keyboard navigation logic to be more resilient to new collections instead --- .../dnd/src/DropTargetKeyboardNavigation.ts | 20 +++++----- .../DropTargetKeyboardNavigation.test.tsx | 4 +- .../grid/src/GridKeyboardDelegate.ts | 24 ++++++------ .../collections/src/getChildNodes.ts | 25 ++++-------- packages/react-aria-components/src/Table.tsx | 39 +++++-------------- 5 files changed, 42 insertions(+), 70 deletions(-) diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index d10c797d525..47da72a73af 100644 --- a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts +++ b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts @@ -11,11 +11,11 @@ export function navigate( ): DropTarget | null { switch (direction) { case 'left': - return rtl + return rtl ? nextDropTarget(keyboardDelegate, collection, target, wrap, 'left') : previousDropTarget(keyboardDelegate, collection, target, wrap, 'left'); case 'right': - return rtl + return rtl ? previousDropTarget(keyboardDelegate, collection, target, wrap, 'right') : nextDropTarget(keyboardDelegate, collection, target, wrap, 'right'); case 'up': @@ -70,7 +70,7 @@ function nextDropTarget( dropPosition: target.dropPosition }; } - + switch (target.dropPosition) { case 'before': { return { @@ -105,7 +105,7 @@ function nextDropTarget( while (nextItemInSameLevel != null && nextItemInSameLevel.type !== 'item') { nextItemInSameLevel = nextItemInSameLevel.nextKey != null ? collection.getItem(nextItemInSameLevel.nextKey) : null; } - + if (targetNode && nextItemInSameLevel == null && targetNode.parentKey != null) { // If the parent item has an item after it, use the "before" position. let parentNode = collection.getItem(targetNode.parentKey); @@ -261,12 +261,14 @@ function getLastChild(collection: Collection>, key: Key): DropTarg let nextKey = getNextItem(collection, key, key => collection.getKeyAfter(key)); let nextNode = nextKey != null ? collection.getItem(nextKey) : null; if (targetNode && nextNode && nextNode.level > targetNode.level) { - let children = getChildNodes(targetNode, collection); - let lastChild: Node | null = null; - for (let child of children) { - if (child.type === 'item') { - lastChild = child; + let lastChild; + if ('lastChildKey' in targetNode) { + lastChild = targetNode.lastChildKey != null ? collection.getItem(targetNode.lastChildKey) : null; + while (lastChild && lastChild.type !== 'item' && lastChild.prevKey != null) { + lastChild = collection.getItem(lastChild.prevKey)!; } + } else { + lastChild = Array.from(targetNode.childNodes).findLast(item => item.type === 'item'); } if (lastChild) { diff --git a/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx b/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx index 1fddd436602..0f39ab326a5 100644 --- a/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx +++ b/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx @@ -186,7 +186,7 @@ class TestCollection implements Collection> { getChildren(key: Key): Iterable> { let item = this.map.get(key); - return item?.childNodes ?? []; + return Array.from(item?.childNodes || [])?.filter(item => item.type !== 'item') ?? []; } } @@ -294,7 +294,7 @@ describe('drop target keyboard navigation', () => { 'reports-2', 'reports-2-content' ]; - + expect(nextKeys).toEqual(expectedKeys); let prevKeys: Key[] = []; diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index e9201559175..b1fceaf3cb3 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -206,8 +206,8 @@ export class GridKeyboardDelegate> implements Key if (this.isRow(item)) { let children = getChildNodes(item, this.collection); return (this.direction === 'rtl' - ? getLastItem(children, item => item.type === 'cell')?.key - : getFirstItem(children, item => item.type === 'cell')?.key) ?? null; + ? getLastItem(children)?.key + : getFirstItem(children)?.key) ?? null; } // If focus is on a cell, focus the next cell if any, @@ -219,8 +219,8 @@ export class GridKeyboardDelegate> implements Key } let children = getChildNodes(parent, this.collection); let next = (this.direction === 'rtl' - ? getNthItem(children, item.index - 1, item => item.type === 'cell') - : getNthItem(children, item.index + 1, item => item.type === 'cell')) ?? null; + ? getNthItem(children, item.index - 1) + : getNthItem(children, item.index + 1)) ?? null; if (next) { return next.key ?? null; @@ -246,8 +246,8 @@ export class GridKeyboardDelegate> implements Key if (this.isRow(item)) { let children = getChildNodes(item, this.collection); return (this.direction === 'rtl' - ? getFirstItem(children, item => item.type === 'cell')?.key - : getLastItem(children, item => item.type === 'cell')?.key) ?? null; + ? getFirstItem(children)?.key + : getLastItem(children)?.key) ?? null; } // If focus is on a cell, focus the previous cell if any, @@ -259,8 +259,8 @@ export class GridKeyboardDelegate> implements Key } let children = getChildNodes(parent, this.collection); let prev = (this.direction === 'rtl' - ? getNthItem(children, item.index + 1, item => item.type === 'cell') - : getNthItem(children, item.index - 1, item => item.type === 'cell')) ?? null; + ? getNthItem(children, item.index + 1) + : getNthItem(children, item.index - 1)) ?? null; if (prev) { return prev.key ?? null; @@ -292,7 +292,7 @@ export class GridKeyboardDelegate> implements Key if (!parent) { return null; } - return getFirstItem(getChildNodes(parent, this.collection), item => item.type === 'cell')?.key ?? null; + return getFirstItem(getChildNodes(parent, this.collection))?.key ?? null; } } @@ -305,7 +305,7 @@ export class GridKeyboardDelegate> implements Key if (!item) { return null; } - key = getFirstItem(getChildNodes(item, this.collection), item => item.type === 'cell')?.key ?? null; + key = getFirstItem(getChildNodes(item, this.collection))?.key ?? null; } // Otherwise, focus the row itself. @@ -329,7 +329,7 @@ export class GridKeyboardDelegate> implements Key return null; } let children = getChildNodes(parent, this.collection); - return getLastItem(children, item => item.type === 'cell')?.key ?? null; + return getLastItem(children)?.key ?? null; } } @@ -343,7 +343,7 @@ export class GridKeyboardDelegate> implements Key return null; } let children = getChildNodes(item, this.collection); - key = getLastItem(children, item => item.type === 'cell')?.key ?? null; + key = getLastItem(children)?.key ?? null; } // Otherwise, focus the row itself. diff --git a/packages/@react-stately/collections/src/getChildNodes.ts b/packages/@react-stately/collections/src/getChildNodes.ts index d5f1ce5c0f7..f81aa494634 100644 --- a/packages/@react-stately/collections/src/getChildNodes.ts +++ b/packages/@react-stately/collections/src/getChildNodes.ts @@ -22,11 +22,11 @@ export function getChildNodes(node: Node, collection: Collection>) return node.childNodes; } -export function getFirstItem(iterable: Iterable, pred?: (item: T) => boolean): T | undefined { - return getNthItem(iterable, 0, pred); +export function getFirstItem(iterable: Iterable): T | undefined { + return getNthItem(iterable, 0); } -export function getNthItem(iterable: Iterable, index: number, pred?: (item: T) => boolean): T | undefined { +export function getNthItem(iterable: Iterable, index: number): T | undefined { if (index < 0) { return undefined; } @@ -34,28 +34,17 @@ export function getNthItem(iterable: Iterable, index: number, pred?: (item let i = 0; for (let item of iterable) { if (i === index) { - if (!pred || pred(item)) { - return item; - } - - return undefined; + return item; } - if (!pred || pred(item)) { - i++; - } + i++; } - - // should only reach here if the entire iterable didn't contain a nth item that matched the pred - return undefined; } -export function getLastItem(iterable: Iterable, pred?: (item: T) => boolean): T | undefined { +export function getLastItem(iterable: Iterable): T | undefined { let lastItem: T | undefined = undefined; for (let value of iterable) { - if (!pred || pred(value)) { - lastItem = value; - } + lastItem = value; } return lastItem; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 1be0e8f4621..3e68b7bc624 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -210,7 +210,7 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection | null; let node = parent?.firstChildKey != null ? self.getItem(parent.firstChildKey) as CollectionNode | null : null; while (node) { - // note that we are returning ALL children not just cells now aka including child rows. Keep this in mind when getting a row's children - // since previously we had assumed this would only be cells yield node as Node; node = node.nextKey != null ? self.getItem(node.nextKey) as CollectionNode | null : null; + + // Return only cells as children of rows (nested rows are flattened into the body). + if (parent?.type === 'item' && node?.type !== 'cell') { + break; + } } } }; @@ -284,10 +287,6 @@ class TableCollection extends BaseCollection implements ITableCollection extends CollectionNode { static readonly type = 'item'; filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): TableRowNode | null { - for (let cell of collection.getChildren(this.key)) { - if (cell.type !== 'cell') { - // todo: skip child rows? this keeps it in line with previous behavior but we'll want to consider allowing customizations for - // this (keep/skip child rows). Also applies to Tree https://github.com/orgs/adobe/projects/19/views/4?sliceBy%5Bvalue%5D=Autocomplete&pane=issue&itemId=164093367 - continue; - } + let cells = collection.getChildren(this.key); + for (let cell of cells) { if (filterFn(cell.textValue, cell)) { let clone = this.clone(); newCollection.addDescendants(clone, collection); @@ -1318,7 +1313,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); - let {isVirtualized} = useContext(CollectionRendererContext); + let {isVirtualized, CollectionBranch} = useContext(CollectionRendererContext); let {rowProps, expandButtonProps, ...states} = useTableRow( { node: item, @@ -1397,20 +1392,6 @@ export const Row = /*#__PURE__*/ createBranchComponent( } }); - let children = useCachedChildren({ - items: state.collection.getChildren!(item.key), - children: item => { - switch (item.type) { - // skip loader and child rows since these are flattened in the body and are rendered as siblings, just like in Tree - case 'loader': - case 'item': - return <>; - default: - return item.render!(item); - } - } - }); - let DOMProps = filterDOMProps(props as any, {global: true}); delete DOMProps.id; delete DOMProps.onClick; @@ -1463,7 +1444,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( }], [SelectionIndicatorContext, {isSelected: states.isSelected}] ]}> - {children} + From 6094397f1c36c15a907992c88c2f6a7a3054c629 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 10 Mar 2026 15:08:33 -0700 Subject: [PATCH 3/5] fix lint --- packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index 47da72a73af..e60e60b7f0f 100644 --- a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts +++ b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts @@ -1,5 +1,4 @@ import {Collection, DropTarget, Key, KeyboardDelegate, Node} from '@react-types/shared'; -import {getChildNodes} from '@react-stately/collections'; export function navigate( keyboardDelegate: KeyboardDelegate, @@ -261,14 +260,14 @@ function getLastChild(collection: Collection>, key: Key): DropTarg let nextKey = getNextItem(collection, key, key => collection.getKeyAfter(key)); let nextNode = nextKey != null ? collection.getItem(nextKey) : null; if (targetNode && nextNode && nextNode.level > targetNode.level) { - let lastChild; + let lastChild: Node | null = null; if ('lastChildKey' in targetNode) { lastChild = targetNode.lastChildKey != null ? collection.getItem(targetNode.lastChildKey) : null; while (lastChild && lastChild.type !== 'item' && lastChild.prevKey != null) { lastChild = collection.getItem(lastChild.prevKey)!; } } else { - lastChild = Array.from(targetNode.childNodes).findLast(item => item.type === 'item'); + lastChild = Array.from(targetNode.childNodes).findLast(item => item.type === 'item') || null; } if (lastChild) { From ef05f09009ccaea61a8dcfee0e131d0873c2a47f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Mar 2026 15:13:10 -0700 Subject: [PATCH 4/5] review comments --- .../dnd/test/DropTargetKeyboardNavigation.test.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx b/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx index 0f39ab326a5..05eb6df7878 100644 --- a/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx +++ b/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx @@ -39,6 +39,15 @@ let rows: Item[] = [ ]} ]; +function toArray(iterableCollection: Iterable>, predicate) { + const result: Node[] = []; + for (const o of iterableCollection) { + if (predicate(o)) { + result.push(o); + } + } + return result; +} // Collection implementation backed by item objects above. // This way we don't need to render React components to test. class TestCollection implements Collection> { @@ -186,7 +195,7 @@ class TestCollection implements Collection> { getChildren(key: Key): Iterable> { let item = this.map.get(key); - return Array.from(item?.childNodes || [])?.filter(item => item.type !== 'item') ?? []; + return toArray(item?.childNodes || [], (node) => node.type !== 'content'); } } From ceb54e46fb14a33b3fd5d836ce1eef9909dfcd3e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Mar 2026 15:15:33 -0700 Subject: [PATCH 5/5] copy pasta --- .../@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx b/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx index 05eb6df7878..164a5a43d1e 100644 --- a/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx +++ b/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx @@ -195,7 +195,7 @@ class TestCollection implements Collection> { getChildren(key: Key): Iterable> { let item = this.map.get(key); - return toArray(item?.childNodes || [], (node) => node.type !== 'content'); + return toArray(item?.childNodes || [], (node) => node.type !== 'item'); } }