Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/react-aria-components/example/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
81 changes: 81 additions & 0 deletions packages/react-aria-components/stories/Table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1970,3 +1970,84 @@ export const TableSectionDnd: TableStory = (args) => {
</Table>
);
};

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 (
<Table
aria-label="Files"
selectionMode="multiple"
treeColumn="name"
className={styles.treeGridTable}
defaultExpandedKeys={['1', '2', '5']}
dragAndDropHooks={dragAndDropHooks}>
<TableHeader>
<Column />
<Column><MyCheckbox slot="selection" /></Column>
<Column id="name" isRowHeader>Name</Column>
<Column id="type">Type</Column>
<Column id="date">Date Modified</Column>
</TableHeader>
<TableBody items={tree.items}>
{function renderItem(item) {
return (
<Row id={item.key} textValue={item.value.title}>
<Cell><Button slot="drag">≡</Button></Cell>
<Cell><MyCheckbox slot="selection" /></Cell>
<NameCell>{item.value.title}</NameCell>
<Cell>{item.value.type}</Cell>
<Cell>{item.value.date}</Cell>
{item.children && <Collection items={item.children}>{renderItem}</Collection>}
</Row>
);
}}
</TableBody>
</Table>
);
};
8 changes: 8 additions & 0 deletions packages/react-aria-components/stories/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 38 additions & 0 deletions packages/react-aria-components/test/Table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TreeGridTableDnd />);
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', () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/react-aria/src/collections/CollectionBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ function useSSRCollectionNode<T extends Element>(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
Expand All @@ -150,6 +153,9 @@ function useSSRCollectionNode<T extends Element>(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();
Expand All @@ -162,7 +168,7 @@ function useSSRCollectionNode<T extends Element>(CollectionNodeClass: Collection
}

// @ts-ignore
return <CollectionNodeClass.type ref={itemRef}>{children}</CollectionNodeClass.type>;
return <CollectionNodeClass.type data-key={key} ref={itemRef}>{children}</CollectionNodeClass.type>;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
30 changes: 27 additions & 3 deletions packages/react-aria/src/collections/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export class ElementNode<T> extends BaseNode<T> {
node: CollectionNode<T> | null;
isMutated = true;
private _index: number = 0;
private _attributesByNameMap = new Map<string, string>();
isHidden = false;

constructor(type: string, ownerDocument: Document<T, any>) {
Expand Down Expand Up @@ -333,7 +334,7 @@ export class ElementNode<T> extends BaseNode<T> {
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();
Expand Down Expand Up @@ -398,9 +399,20 @@ export class ElementNode<T> extends BaseNode<T> {
}

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);
}
}

/**
Expand Down Expand Up @@ -567,3 +579,15 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
}
}
}

function makeId(node: ElementNode<unknown>, identifierPrefix = 'react-aria') {
if (node.parentNode instanceof ElementNode) {
Copy link
Copy Markdown
Contributor

@nwidynski nwidynski May 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (node.parentNode instanceof ElementNode) {
if (node.parentNode !== node.ownerDocument) {

Regarding performance, I think this is faster than traversing the prototype chain. Will need type assertions though.

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);
}
Loading