diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index fee624a4567..d3e24953de2 100644 --- a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts +++ b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts @@ -100,19 +100,25 @@ function nextDropTarget( } case 'after': { // If this is the last sibling in a level, traverse to the parent. - let targetNode = collection.getItem(target.key); - if (targetNode && targetNode.nextKey == null && targetNode.parentKey != null) { + let targetNode = collection.getItem(target.key); + let nextItemInSameLevel = targetNode?.nextKey != null ? collection.getItem(targetNode.nextKey) : null; + 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); - if (parentNode?.nextKey != null) { + const nextNode = parentNode?.nextKey != null ? collection.getItem(parentNode.nextKey) : null; + if (nextNode?.type === 'item') { return { type: 'item', - key: parentNode.nextKey, + key: nextNode.key, dropPosition: 'before' }; } - if (parentNode) { + if (parentNode?.type === 'item') { return { type: 'item', key: parentNode.key, @@ -121,10 +127,10 @@ function nextDropTarget( } } - if (targetNode?.nextKey != null) { + if (nextItemInSameLevel) { return { type: 'item', - key: targetNode.nextKey, + key: nextItemInSameLevel.key, dropPosition: 'on' }; } @@ -154,8 +160,11 @@ function previousDropTarget( let prevKey: Key | null = null; let lastKey = keyboardDelegate.getLastKey?.(); while (lastKey != null) { - prevKey = lastKey; let node = collection.getItem(lastKey); + if (node?.type !== 'item') { + break; + } + prevKey = lastKey; lastKey = node?.parentKey; } diff --git a/packages/@react-spectrum/table/test/TableDnd.test.js b/packages/@react-spectrum/table/test/TableDnd.test.js index a97231f92a7..652940958b1 100644 --- a/packages/@react-spectrum/table/test/TableDnd.test.js +++ b/packages/@react-spectrum/table/test/TableDnd.test.js @@ -1967,6 +1967,49 @@ describe('TableView', function () { }); }); + it('support drop target keyboard navigation', async () => { + render(); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + const labels = ['Vin Charlet', 'Lexy Maddison', 'Robbi Persence', 'Dodie Hurworth', 'Audrye Hember', 'Beau Oller', 'Roarke Gration', 'Cathy Lishman', 'Enrika Soitoux', 'Aloise Tuxsell']; + + for (let i = 0; i < labels.length; i++) { + if (i === labels.length - 1) { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert after ${labels[i]}`); + } else { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert between ${labels[i]} and ${labels[i + 1]}`); + } + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + } + + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Vin Charlet'); + await user.keyboard('{End}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Aloise Tuxsell'); + + for (let i = labels.length - 1; i >= 0; i--) { + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + if (i === 0) { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert before ${labels[i]}`); + } else { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert between ${labels[i - 1]} and ${labels[i]}`); + } + } + + await user.keyboard('{ArrowUp}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Aloise Tuxsell'); + await user.keyboard('{Home}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Vin Charlet'); + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + }); + describe('using util handlers', function () { async function beginDrag(tree) { let grids = tree.getAllByRole('grid'); diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index ed1bc25174b..d0e27e11e02 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -195,7 +195,7 @@ export function renderAfterDropIndicators(collection: ICollection> let afterIndicators: ReactNode[] = []; if (nextItemInSameLevel == null) { let current: Node | null = node; - while (current && (!nextItemInFlattenedCollection || (current.parentKey !== nextItemInFlattenedCollection.parentKey && nextItemInFlattenedCollection.level < current.level))) { + while (current?.type === 'item' && (!nextItemInFlattenedCollection || (current.parentKey !== nextItemInFlattenedCollection.parentKey && nextItemInFlattenedCollection.level < current.level))) { let indicator = renderDropIndicator({ type: 'item', key: current.key, diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 451d50eb9ba..f8280e807ee 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -205,9 +205,14 @@ outline: 1px solid slateblue; } - :global(.react-aria-Table) { border-collapse: collapse; + + &[data-drop-target] { + outline: 2px solid purple; + outline-offset: -2px; + background: rgb(from purple r g b / 20%); + } } :global(.react-aria-Cell) { diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 667096ceabd..8074ff97020 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1310,6 +1310,56 @@ describe('Table', () => { expect(checkbox).toBeChecked(); } }); + + it('support drop target keyboard navigation', async () => { + const DndTableExample = stories.DndTableExample; + render(); + 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'); + + const labels = ['Pictures', 'Adobe Fresco', 'Apps', 'Adobe Illustrator', 'Adobe Lightroom', 'Adobe Dreamweaver']; + + for (let i = 0; i <= labels.length; i++) { + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + + if (i === 0) { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert before ${labels[i]}`); + } else if (i === labels.length) { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert after ${labels[i - 1]}`); + } else { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert between ${labels[i - 1]} and ${labels[i]}`); + } + } + + await user.keyboard('{Home}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); + + for (let i = labels.length; i >= 0; i--) { + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + if (i === 0) { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert before ${labels[i]}`); + } else if (i === labels.length) { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert after ${labels[i - 1]}`); + } else { + expect(document.activeElement).toHaveAttribute('aria-label', `Insert between ${labels[i - 1]} and ${labels[i]}`); + } + } + + await user.keyboard('{End}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Adobe Dreamweaver'); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + }); }); describe('column resizing', () => {