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 &&