From 2affbf5d866668b08eb309a4e5d52cc626d9027f Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:52:18 +0000 Subject: [PATCH 1/2] fix(Table): Fix focus behavior after moving items to a different level via keyboard DnD --- .../react-aria-components/example/index.css | 8 ++ .../stories/Table.stories.tsx | 81 +++++++++++++++++++ .../react-aria-components/stories/styles.css | 8 ++ .../react-aria-components/test/Table.test.js | 38 +++++++++ .../src/dnd/useDroppableCollection.ts | 8 +- 5 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/example/index.css b/packages/react-aria-components/example/index.css index 00e2501062b..53219375f80 100644 --- a/packages/react-aria-components/example/index.css +++ b/packages/react-aria-components/example/index.css @@ -520,3 +520,11 @@ input { top: 50%; left: 50%; } + +.treeGridTable { + overflow: clip; + + :global(.react-aria-DropIndicator)[data-drop-target] { + translate: calc(50px + (var(--table-row-level) - 1) * 15px) 0; + } +} diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 0e5076fcc36..759972cbc46 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -1970,3 +1970,84 @@ export const TableSectionDnd: TableStory = (args) => { ); }; + +export const TreeGridTableDnd: TableStory = () => { + 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: typeof tree.items) { + return items.map((item) => { + let serializeItem = (nodeItem) => ({ + ...nodeItem.value, + children: nodeItem.children?.map(serializeItem) ?? [] + }); + return { + 'text/plain': item.value.title, + 'tree-item': JSON.stringify(serializeItem(item)) + }; + }); + }, + 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 ( + + + + {item.value.title} + {item.value.type} + {item.value.date} + {item.children && {renderItem}} + + ); + }} + +
+ ); +}; diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 6d1079ef57c..55e3c2903e3 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -221,6 +221,14 @@ } } +:global(.react-aria-Row) { + &[data-drop-target] { + outline: 2px solid purple; + background: rgb(from purple r g b / 20%); + z-index: 4; + } +} + :global(.react-aria-Cell) { overflow: hidden; white-space: nowrap; diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index b5e8bdea8f8..7518e800609 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1582,6 +1582,44 @@ describe('Table', () => { expect(tableTester.rows).toHaveLength(8); expect(tableTester.selectedRows).toHaveLength(1); }); + + it('should focus the item after moving it to a different level', async () => { + const TreeGridTableDnd = stories.TreeGridTableDnd; + let {getAllByRole} = render(); + let tableTester = testUtilUser.createTester('Table', {root: getAllByRole('treegrid')[0]}); + expect(tableTester.rows).toHaveLength(7); + await user.tab(); + for (let i = 0; i < 3; i++) { + 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}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Project'); + await act(async () => { + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + }); + act(() => jest.runAllTimers()); + expect(tableTester.rows).toHaveLength(7); + expect(document.activeElement).toBe(tableTester.rows[3]); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + for (let i = 0; i < 3; i++) { + await user.keyboard('{ArrowDown}'); + } + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Image 1'); + await act(async () => { + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + }); + act(() => jest.runAllTimers()); + expect(tableTester.rows).toHaveLength(7); + expect(document.activeElement).toBe(tableTester.rows[4]); + }); }); describe('column resizing', () => { diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index a832386e5aa..93992cb0eed 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -245,6 +245,8 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: draggingKeys } = droppingState.current; + let prevFocusedItem = prevFocusedKey != null ? (state.collection.getItem(prevFocusedKey) ?? prevCollection.getItem(prevFocusedKey)) : null; + // If an insert occurs during a drop, we want to immediately select these items to give // feedback to the user that a drop occurred. Only do this if the selection didn't change // since the drop started so we don't override if the user or application did something. @@ -296,15 +298,15 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: } } } else if ( - prevFocusedKey != null && + prevFocusedItem != null && state.selectionManager.focusedKey === prevFocusedKey && isInternal && target.type === 'item' && target.dropPosition !== 'on' && - draggingKeys.has(state.collection.getItem(prevFocusedKey)?.parentKey) + draggingKeys.has(prevFocusedItem.parentKey) ) { // Focus row instead of cell when reordering. - state.selectionManager.setFocusedKey(state.collection.getItem(prevFocusedKey)?.parentKey ?? null); + state.selectionManager.setFocusedKey(prevFocusedItem.parentKey ?? null); setInteractionModality('keyboard'); } else if ( state.selectionManager.focusedKey === prevFocusedKey && From bba3730cc854846e163340426f25e5757510fca0 Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Sat, 2 May 2026 15:33:32 +0000 Subject: [PATCH 2/2] chore(Collections): use stable ids based on parentKey --- .../src/collections/CollectionBuilder.tsx | 8 ++++- .../react-aria/src/collections/Document.ts | 30 +++++++++++++++++-- .../src/dnd/useDroppableCollection.ts | 8 ++--- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/react-aria/src/collections/CollectionBuilder.tsx b/packages/react-aria/src/collections/CollectionBuilder.tsx index e93746c392f..2d20f9b5322 100644 --- a/packages/react-aria/src/collections/CollectionBuilder.tsx +++ b/packages/react-aria/src/collections/CollectionBuilder.tsx @@ -136,6 +136,9 @@ function useSSRCollectionNode(CollectionNodeClass: Collection CollectionNodeClass = createCollectionNodeClass(CollectionNodeClass); } + // @ts-ignore + const key = props.id ?? undefined; + // During SSR, portals are not supported, so the collection children will be wrapped in an SSRContext. // Since SSR occurs only once, we assume that the elements are rendered in order and never re-render. // Therefore we can create elements in our collection document during render so that they are in the @@ -150,6 +153,9 @@ function useSSRCollectionNode(CollectionNodeClass: Collection let element = parentNode.ownerDocument.nodesByProps.get(props); if (!element) { element = parentNode.ownerDocument.createElement(CollectionNodeClass.type); + if (key != null) { + element.setAttribute('data-key', '' + key); + } element.setProps(props, ref, CollectionNodeClass, rendered, render); parentNode.appendChild(element); parentNode.ownerDocument.updateCollection(); @@ -162,7 +168,7 @@ function useSSRCollectionNode(CollectionNodeClass: Collection } // @ts-ignore - return {children}; + return {children}; } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/react-aria/src/collections/Document.ts b/packages/react-aria/src/collections/Document.ts index d4beac8d736..cde6dc13588 100644 --- a/packages/react-aria/src/collections/Document.ts +++ b/packages/react-aria/src/collections/Document.ts @@ -260,6 +260,7 @@ export class ElementNode extends BaseNode { node: CollectionNode | null; isMutated = true; private _index: number = 0; + private _attributesByNameMap = new Map(); isHidden = false; constructor(type: string, ownerDocument: Document) { @@ -333,7 +334,7 @@ export class ElementNode extends BaseNode { let node; let {value, textValue, id, ...props} = obj; if (this.node == null) { - node = new CollectionNodeClass(id ?? `react-aria-${++this.ownerDocument.nodeId}`); + node = new CollectionNodeClass(id ?? makeId(this)); this.node = node; } else { node = this.getMutableNode(); @@ -398,9 +399,20 @@ export class ElementNode extends BaseNode { } hasAttribute(): void {} - setAttribute(): void {} + + setAttribute(qualifiedName: string, value: string): void { + this._attributesByNameMap.set(qualifiedName, '' + value); + } + + getAttribute(qualifiedName: string): string | null { + return this._attributesByNameMap.get(qualifiedName) ?? null; + } + setAttributeNS(): void {} - removeAttribute(): void {} + + removeAttribute(qualifiedName: string): void { + this._attributesByNameMap.delete(qualifiedName); + } } /** @@ -567,3 +579,15 @@ export class Document = BaseCollection> extend } } } + +function makeId(node: ElementNode, identifierPrefix = 'react-aria') { + if (node.parentNode instanceof ElementNode) { + const parentKey = node.parentNode.getAttribute('data-key'); + if (parentKey != null) { + // If parentNode specifies a key, generate a stable id based on parentKey + // so that useDroppableCollection can keep track of each child even if it is recreated + return identifierPrefix + '-' + parentKey + '-' + node.index.toString(32); + } + } + return identifierPrefix + '-' + (++node.ownerDocument.nodeId).toString(32); +} diff --git a/packages/react-aria/src/dnd/useDroppableCollection.ts b/packages/react-aria/src/dnd/useDroppableCollection.ts index 93992cb0eed..a832386e5aa 100644 --- a/packages/react-aria/src/dnd/useDroppableCollection.ts +++ b/packages/react-aria/src/dnd/useDroppableCollection.ts @@ -245,8 +245,6 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: draggingKeys } = droppingState.current; - let prevFocusedItem = prevFocusedKey != null ? (state.collection.getItem(prevFocusedKey) ?? prevCollection.getItem(prevFocusedKey)) : null; - // If an insert occurs during a drop, we want to immediately select these items to give // feedback to the user that a drop occurred. Only do this if the selection didn't change // since the drop started so we don't override if the user or application did something. @@ -298,15 +296,15 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: } } } else if ( - prevFocusedItem != null && + prevFocusedKey != null && state.selectionManager.focusedKey === prevFocusedKey && isInternal && target.type === 'item' && target.dropPosition !== 'on' && - draggingKeys.has(prevFocusedItem.parentKey) + draggingKeys.has(state.collection.getItem(prevFocusedKey)?.parentKey) ) { // Focus row instead of cell when reordering. - state.selectionManager.setFocusedKey(prevFocusedItem.parentKey ?? null); + state.selectionManager.setFocusedKey(state.collection.getItem(prevFocusedKey)?.parentKey ?? null); setInteractionModality('keyboard'); } else if ( state.selectionManager.focusedKey === prevFocusedKey &&