From 78ffcc1665c60e7d4b988e94e081a1187e4adef3 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 2 Apr 2026 12:59:26 -0700 Subject: [PATCH 1/7] fix: focus newly added table row on drop --- .../react-aria-components/test/Table.test.js | 23 +++++++++++++++++++ .../src/dnd/useDroppableCollection.ts | 7 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 7c9262292af..8b1a4f43b00 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1515,6 +1515,29 @@ describe('Table', () => { await user.keyboard('{Escape}'); act(() => jest.runAllTimers()); }); + + it('should select dropped item', async () => { + const DndTableExample = stories.DndTableExample; + let {getAllByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getAllByRole('grid')[1]}); + expect(tableTester.rows).toHaveLength(7); + expect(tableTester.selectedRows).toHaveLength(0); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Adobe Photoshop and Adobe XD'); + await user.tab(); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Pictures'); + await user.keyboard('{Enter}'); + // run onInsert promise in DnDTableExample first, otherwise updateFocusAfterDrop doesn't run properly + await act(async () => {}); + act(() => jest.runAllTimers()); + expect(tableTester.rows).toHaveLength(8); + expect(tableTester.selectedRows).toHaveLength(1); + }); }); describe('column resizing', () => { diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index ef56edfa27d..b1c5041774d 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -253,10 +253,13 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: state.selectionManager.isSelectionEqual(prevSelectedKeys) ) { let newKeys = new Set(); - for (let item of state.collection) { - if (item.type === 'item' && !prevCollection.getItem(item.key)) { + let key = state.collection.getFirstKey(); + while (key != null) { + let item = state.collection.getItem(key); + if (item?.type === 'item' && !prevCollection.getItem(item.key)) { newKeys.add(item.key); } + key = state.collection.getKeyAfter(key); } state.selectionManager.setSelectedKeys(newKeys); From c31f48e1dca7bfc2c4b76673fb2b3df6bfbc7525 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 2 Apr 2026 13:11:06 -0700 Subject: [PATCH 2/7] fix test --- packages/react-aria-components/test/Table.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 8b1a4f43b00..0eb54b1cff1 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1531,7 +1531,8 @@ describe('Table', () => { expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Pictures'); - await user.keyboard('{Enter}'); + act(() => fireEvent.keyDown(document.activeElement, {key: 'Enter'})); + act(() => fireEvent.keyUp(document.activeElement, {key: 'Enter'})); // run onInsert promise in DnDTableExample first, otherwise updateFocusAfterDrop doesn't run properly await act(async () => {}); act(() => jest.runAllTimers()); From e2b2e950abb5139f264d73c8999fa0a6012d6f59 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 2 Apr 2026 15:17:03 -0700 Subject: [PATCH 3/7] fix 16/17 tests --- packages/react-aria-components/test/Table.test.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 0eb54b1cff1..39c81d2c3f4 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1531,8 +1531,12 @@ describe('Table', () => { expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Pictures'); - act(() => fireEvent.keyDown(document.activeElement, {key: 'Enter'})); - act(() => fireEvent.keyUp(document.activeElement, {key: 'Enter'})); + if (parseInt(React.version, 10) >= 18) { + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + } else { + await user.keyboard('{Enter}'); + } // run onInsert promise in DnDTableExample first, otherwise updateFocusAfterDrop doesn't run properly await act(async () => {}); act(() => jest.runAllTimers()); From d752135ab5faaea6dc0d3868477f645a5e4a9bbb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 2 Apr 2026 15:19:11 -0700 Subject: [PATCH 4/7] actually just need to remove the acts? --- packages/react-aria-components/test/Table.test.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 39c81d2c3f4..2619bd546ba 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1531,12 +1531,8 @@ describe('Table', () => { expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Pictures'); - if (parseInt(React.version, 10) >= 18) { - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - } else { - await user.keyboard('{Enter}'); - } + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); // run onInsert promise in DnDTableExample first, otherwise updateFocusAfterDrop doesn't run properly await act(async () => {}); act(() => jest.runAllTimers()); From 9b90f436456cf3af342e0762e812808ccfe16403 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Apr 2026 14:12:12 -0700 Subject: [PATCH 5/7] fix focus issues when dropping on a collapsed/expanded tree item and select all child rows on drop --- .../react-aria-components/test/Tree.test.tsx | 125 ++++++++++++++++++ .../src/dnd/useDroppableCollection.ts | 12 +- 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 30eb7c111ca..4a97c220898 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -2045,6 +2045,131 @@ describe('Tree', () => { expect(getItems).toHaveBeenCalledTimes(1); expect(getItems).toHaveBeenCalledWith(new Set(['projects', 'reports'])); }); + + it('should select the parent and all its children when dropped', async () => { + let {getAllByRole} = render(); + let trees = getAllByRole('treegrid'); + + let firstTreeTester = testUtilUser.createTester('Tree', {root: trees[0]}); + let secondTreeTester = testUtilUser.createTester('Tree', {root: trees[1]}); + expect(firstTreeTester.rows).toHaveLength(2); + // has the empty state row + expect(secondTreeTester.rows).toHaveLength(1); + await user.tab(); + // selects and drops first row onto second tree + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + await user.tab(); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + // run onInsert promise + await act(async () => {}); + act(() => jest.runAllTimers()); + expect(secondTreeTester.rows).toHaveLength(1); + // expands tree row children + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(secondTreeTester.selectedRows).toHaveLength(9); + }); + + it('should focus the parent row when dropped on if it isnt expanded', async () => { + let {getAllByRole} = render(); + let trees = getAllByRole('treegrid'); + + let firstTreeTester = testUtilUser.createTester('Tree', {root: trees[0]}); + let secondTreeTester = testUtilUser.createTester('Tree', {root: trees[1]}); + expect(firstTreeTester.rows).toHaveLength(2); + // has the empty state row + expect(secondTreeTester.rows).toHaveLength(1); + await user.tab(); + // selects and drops first row onto second tree + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + await user.tab(); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); + // run onInsert promise + await act(async () => {}); + act(() => jest.runAllTimers()); + expect(secondTreeTester.rows).toHaveLength(1); + await user.keyboard('{ArrowRight}'); + expect(secondTreeTester.rows).toHaveLength(6); + // tab back to the first tree and drop a new row onto one of the 2nd tree's child rows as it is expanded + await user.tab({shift: true}); + expect(document.activeElement).toBe(firstTreeTester.rows[0]); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + await user.tab(); + for (let i = 0; i < 7; i++) { + await user.keyboard('{ArrowDown}'); + } + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Project 2'); + await user.keyboard('{Enter}'); + await act(async () => {}); + act(() => jest.runAllTimers()); + expect(document.activeElement).toBe(secondTreeTester.rows[2]); + }); + + it('should focus the dropped row when dropped on a parent that is expanded', async () => { + let {getAllByRole} = render(); + let trees = getAllByRole('treegrid'); + + let firstTreeTester = testUtilUser.createTester('Tree', {root: trees[0]}); + let secondTreeTester = testUtilUser.createTester('Tree', {root: trees[1]}); + expect(firstTreeTester.rows).toHaveLength(2); + // has the empty state row + expect(secondTreeTester.rows).toHaveLength(1); + await user.tab(); + // selects and drops first row onto second tree + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + await user.tab(); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); + // run onInsert promise + await act(async () => {}); + act(() => jest.runAllTimers()); + expect(secondTreeTester.rows).toHaveLength(1); + // expands tree row children + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(secondTreeTester.rows).toHaveLength(9); + // tab back to the first tree and drop a new row onto one of the 2nd tree's child rows as it is expanded + await user.tab({shift: true}); + expect(document.activeElement).toBe(firstTreeTester.rows[0]); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + act(() => jest.runAllTimers()); + await user.tab(); + for (let i = 0; i < 14; i++) { + await user.keyboard('{ArrowDown}'); + } + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Project 2'); + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); + await act(async () => {}); + act(() => jest.runAllTimers()); + expect(document.activeElement).toHaveTextContent('Projects'); + expect(document.activeElement).toBe(secondTreeTester.rows[3]); + + }); }); describe('press events', () => { diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index b1c5041774d..a832386e5aa 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -259,7 +259,12 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: if (item?.type === 'item' && !prevCollection.getItem(item.key)) { newKeys.add(item.key); } - key = state.collection.getKeyAfter(key); + + if (item?.hasChildNodes && state.collection.getItem(item.lastChildKey!)?.type === 'item') { + key = item.firstChildKey!; + } else { + key = state.collection.getKeyAfter(key); + } } state.selectionManager.setSelectedKeys(newKeys); @@ -271,10 +276,11 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: let first: Key | null | undefined = newKeys.keys().next().value; if (first != null) { let item = state.collection.getItem(first); - + let dropTarget = droppingState.current.target; + let isParentRowExpanded = state.collection['expandedKeys'] ? state.collection['expandedKeys'].has(item?.parentKey) : false; // If this is a cell, focus the parent row. // eslint-disable-next-line max-depth - if (item?.type === 'cell') { + if (item && (item?.type === 'cell' || (dropTarget.type === 'item' && dropTarget.dropPosition === 'on' && !isParentRowExpanded))) { first = item.parentKey; } From 00b3a2e728661f3f9d2fe128993683cff287a6c4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Apr 2026 14:28:09 -0700 Subject: [PATCH 6/7] lint --- packages/react-aria-components/test/Tree.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 4a97c220898..8fb6da81d6c 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -2064,8 +2064,8 @@ describe('Tree', () => { act(() => jest.runAllTimers()); await user.tab(); expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); // run onInsert promise await act(async () => {}); act(() => jest.runAllTimers()); From f53761129bfacf61ba814d59efe7b92d2a5e1c5d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 7 Apr 2026 10:38:30 -0700 Subject: [PATCH 7/7] fix tests so the original bug is caught when using old useDroppableCollection --- .../react-aria-components/test/Tree.test.tsx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 8fb6da81d6c..434ac46e58a 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -2064,10 +2064,10 @@ describe('Tree', () => { act(() => jest.runAllTimers()); await user.tab(); expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); - fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); - // run onInsert promise - await act(async () => {}); + await act(async () => { + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); + }); act(() => jest.runAllTimers()); expect(secondTreeTester.rows).toHaveLength(1); // expands tree row children @@ -2095,10 +2095,10 @@ describe('Tree', () => { act(() => jest.runAllTimers()); await user.tab(); expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); - fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); - // run onInsert promise - await act(async () => {}); + await act(async () => { + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); + }); act(() => jest.runAllTimers()); expect(secondTreeTester.rows).toHaveLength(1); await user.keyboard('{ArrowRight}'); @@ -2114,8 +2114,10 @@ describe('Tree', () => { await user.keyboard('{ArrowDown}'); } expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Project 2'); - await user.keyboard('{Enter}'); - await act(async () => {}); + await act(async () => { + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); + }); act(() => jest.runAllTimers()); expect(document.activeElement).toBe(secondTreeTester.rows[2]); }); @@ -2138,10 +2140,10 @@ describe('Tree', () => { await user.tab(); expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); - fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); - // run onInsert promise - await act(async () => {}); + await act(async () => { + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); + }); act(() => jest.runAllTimers()); expect(secondTreeTester.rows).toHaveLength(1); // expands tree row children @@ -2162,9 +2164,10 @@ describe('Tree', () => { await user.keyboard('{ArrowDown}'); } expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Project 2'); - fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); - await act(async () => {}); + await act(async () => { + fireEvent.keyDown(document.activeElement as Element, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement as Element, {key: 'Enter'}); + }); act(() => jest.runAllTimers()); expect(document.activeElement).toHaveTextContent('Projects'); expect(document.activeElement).toBe(secondTreeTester.rows[3]);