From c14db17ad31d82fc9af8eb022472fc8fc13b9555 Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:37:14 +0000 Subject: [PATCH] fix: Update Tree drag and drop focus --- .../dnd/src/useDroppableCollection.ts | 41 ++++++++++++- packages/@react-aria/dnd/src/utils.ts | 9 +++ .../react-aria-components/test/Tree.test.tsx | 57 ++++++++++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/dnd/src/useDroppableCollection.ts b/packages/@react-aria/dnd/src/useDroppableCollection.ts index 555c75e9380..9fcb3e12596 100644 --- a/packages/@react-aria/dnd/src/useDroppableCollection.ts +++ b/packages/@react-aria/dnd/src/useDroppableCollection.ts @@ -14,6 +14,7 @@ import { clearGlobalDnDState, DIRECTORY_DRAG_TYPE, droppableCollectionMap, + getItemElement, getTypes, globalDndState, isInternalDropOperation, @@ -273,6 +274,25 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: first = item.parentKey; } + if (item?.type === 'content') { + let parentKey = item.parentKey; + while (parentKey) { + let parent = state.collection.getItem(parentKey); + if (parent?.type !== 'item') { + parentKey = parent?.parentKey; + continue; + } + // If an item doesn't exist in the collection, + // it's because its parent is collapsed, and we should focus its parent instead. + let item = getItemElement(ref, parentKey); + if (item) { + first = parentKey; + break; + } + parentKey = parent.parentKey; + } + } + // eslint-disable-next-line max-depth if (first != null) { state.selectionManager.setFocusedKey(first); @@ -312,7 +332,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: state.selectionManager.setFocused(true); } - }, [localState]); + }, [localState, ref]); let onDrop = useCallback((e: DropEvent, target: DropTarget) => { let {state} = localState; @@ -445,6 +465,25 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: key = item.parentKey; } + if (item?.type === 'content') { + let parentKey = item.parentKey; + while (parentKey) { + let parent = localState.state.collection.getItem(parentKey); + if (parent?.type !== 'item') { + parentKey = parent?.parentKey; + continue; + } + // If an item doesn't exist in the collection, + // it's because its parent is collapsed, and we should focus its parent instead. + let item = getItemElement(ref, parentKey); + if (item) { + key = parentKey; + break; + } + parentKey = parent.parentKey; + } + } + // If the focused item is also selected, the default drop target is after the last selected item. // But if the focused key is the first selected item, then default to before the first selected item. // This is to make reordering lists slightly easier. If you select top down, we assume you want to diff --git a/packages/@react-aria/dnd/src/utils.ts b/packages/@react-aria/dnd/src/utils.ts index 1fb8fa65abf..0e7fa747c38 100644 --- a/packages/@react-aria/dnd/src/utils.ts +++ b/packages/@react-aria/dnd/src/utils.ts @@ -383,3 +383,12 @@ export let globalAllowedDropOperations: DROP_OPERATION = DROP_OPERATION.none; export function setGlobalAllowedDropOperations(o: DROP_OPERATION): void { globalAllowedDropOperations = o; } + +export function getItemElement(collectionRef: RefObject, key: Key): Element | null | undefined { + let selector = `[data-key="${CSS.escape(String(key))}"]`; + let collection = collectionRef.current?.dataset.collection; + if (collection) { + selector = `[data-collection="${CSS.escape(collection)}"]${selector}`; + } + return collectionRef.current?.querySelector(selector); +} diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 6346062331b..28381047f1f 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -24,7 +24,8 @@ import {useTreeData} from 'react-stately'; let { EmptyTreeStaticStory: EmptyLoadingTree, - LoadingStoryDepOnTopStory: LoadingMoreTree + LoadingStoryDepOnTopStory: LoadingMoreTree, + TreeWithDragAndDrop } = composeStories(stories); let onSelectionChange = jest.fn(); @@ -1924,6 +1925,60 @@ describe('Tree', () => { expect(onRootDrop).toHaveBeenCalledTimes(1); }); + it('should automatically focus the newly added dropped item', async () => { + const {getAllByRole} = render(); + + const trees = getAllByRole('treegrid'); + const firstTreeRows = within(trees[0]).getAllByRole('row'); + const dataTransfer = new DataTransfer(); + + fireEvent(firstTreeRows[1], new DragEvent('dragstart', {dataTransfer, clientX: 5, clientY: 5})); + act(() => jest.runAllTimers()); + + fireEvent(trees[1], new DragEvent('dragenter', {dataTransfer, clientX: 50, clientY: 50})); + fireEvent(trees[1], new DragEvent('dragover', {dataTransfer, clientX: 50, clientY: 50})); + expect(trees[1]).toHaveAttribute('data-drop-target', 'true'); + + // ¯\_(ツ)_/¯ + await act(async () => fireEvent(trees[1], new DragEvent('drop', {dataTransfer, clientX: 50, clientY: 50}))); + act(() => jest.runAllTimers()); + + let secondTreeRows = within(trees[1]).getAllByRole('row'); + + expect(secondTreeRows).toHaveLength(1); + // The newly added row in the second tree should be the active element + expect(secondTreeRows[0]).toBe(document.activeElement); + + await user.click(document.body); + act(() => jest.runAllTimers()); + + await user.tab(); + await user.keyboard('{ArrowRight}'); // expand the projects item + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + await user.tab(); + act(() => jest.runAllTimers()); + + secondTreeRows = within(trees[1]).getAllByRole('row'); + expect(secondTreeRows).toHaveLength(4); + expect(within(secondTreeRows[3]).getAllByRole('button')[0]).toHaveAttribute('aria-label', 'Insert after Reports'); + expect(document.activeElement).toBe(within(secondTreeRows[3]).getAllByRole('button')[0]); + expect(secondTreeRows[3]).toHaveAttribute('data-drop-target', 'true'); + + await user.keyboard('{ArrowUp}'); + expect(within(secondTreeRows[2]).getAllByRole('button')[0]).toHaveAttribute('aria-label', 'Drop on Reports'); + expect(document.activeElement).toBe(within(secondTreeRows[2]).getAllByRole('button')[0]); + + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + secondTreeRows = within(trees[1]).getAllByRole('row'); + expect(secondTreeRows).toHaveLength(1); + expect(secondTreeRows[0]).toBe(document.activeElement); + }); + it('should support disabled drag and drop', async () => { let {getByRole, queryAllByRole} = render(