diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index d10c797d525..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, @@ -11,11 +10,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 +69,7 @@ function nextDropTarget( dropPosition: target.dropPosition }; } - + switch (target.dropPosition) { case 'before': { return { @@ -105,7 +104,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 +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 children = getChildNodes(targetNode, collection); let lastChild: Node | null = null; - for (let child of children) { - if (child.type === 'item') { - lastChild = child; + 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') || null; } if (lastChild) { diff --git a/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx b/packages/@react-aria/dnd/test/DropTargetKeyboardNavigation.test.tsx index 1fddd436602..164a5a43d1e 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 item?.childNodes ?? []; + return toArray(item?.childNodes || [], (node) => node.type !== 'item'); } } @@ -294,7 +303,7 @@ describe('drop target keyboard navigation', () => { 'reports-2', 'reports-2-content' ]; - + expect(nextKeys).toEqual(expectedKeys); let prevKeys: Key[] = []; 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'); + }); });