Skip to content
Merged
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
19 changes: 10 additions & 9 deletions packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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':
Expand Down Expand Up @@ -70,7 +69,7 @@ function nextDropTarget(
dropPosition: target.dropPosition
};
}

switch (target.dropPosition) {
case 'before': {
return {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -261,12 +260,14 @@ function getLastChild(collection: Collection<Node<unknown>>, 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<unknown> | 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ let rows: Item[] = [
]}
];

function toArray(iterableCollection: Iterable<Node<Item>>, predicate) {
const result: Node<Item>[] = [];
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<Node<Item>> {
Expand Down Expand Up @@ -186,7 +195,7 @@ class TestCollection implements Collection<Node<Item>> {

getChildren(key: Key): Iterable<Node<Item>> {
let item = this.map.get(key);
return item?.childNodes ?? [];
return toArray(item?.childNodes || [], (node) => node.type !== 'item');
}
}

Expand Down Expand Up @@ -294,7 +303,7 @@ describe('drop target keyboard navigation', () => {
'reports-2',
'reports-2-content'
];

expect(nextKeys).toEqual(expectedKeys);

let prevKeys: Key[] = [];
Expand Down
211 changes: 138 additions & 73 deletions packages/react-aria-components/test/Treeble.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function Cell(props) {
return (
<AriaCell {...props}>
{composeRenderProps(props.children, (children, {hasChildItems, isTreeColumn}) => (<>
{isTreeColumn && hasChildItems &&
{isTreeColumn && hasChildItems &&
<Button slot="chevron">&gt;</Button>
}
{children}
Expand Down Expand Up @@ -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 (
<Table
aria-label="Files"
selectionMode="multiple"
treeColumn="name"
defaultExpandedKeys={['5']}
dragAndDropHooks={dragAndDropHooks}
{...props}>
<TableHeader>
<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" /></Cell>
<Cell>{item.value.title}</Cell>
<Cell>{item.value.type}</Cell>
<Cell>{item.value.date}</Cell>
{item.children && <Collection items={item.children}>
{renderItem}
</Collection>}
</Row>
);
}}
</TableBody>
</Table>
);
}

describe('Treeble', () => {
let utils = new User();
let user;
Expand All @@ -105,7 +175,7 @@ describe('Treeble', () => {
it('renders a treegrid', () => {
let tree = render(<Example />);
let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')});

expect(tester.table).toHaveAttribute('role', 'treegrid');

expect(tester.rows).toHaveLength(4);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 (
<Table
aria-label="Files"
selectionMode="multiple"
treeColumn="name"
defaultExpandedKeys={['5']}
dragAndDropHooks={dragAndDropHooks}>
<TableHeader>
<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" /></Cell>
<Cell>{item.value.title}</Cell>
<Cell>{item.value.type}</Cell>
<Cell>{item.value.date}</Cell>
{item.children && <Collection items={item.children}>
{renderItem}
</Collection>}
</Row>
);
}}
</TableBody>
</Table>
);
}

let tree = render(<ReorderableTreeble />);
let tester = utils.createTester('Table', {root: tree.getByRole('treegrid')});

Expand Down Expand Up @@ -572,7 +573,7 @@ describe('Treeble', () => {
'Insert after Image 2',
'Insert after Photos'
]);

await user.keyboard('{Enter}');
act(() => jest.runAllTimers());

Expand All @@ -584,4 +585,68 @@ describe('Treeble', () => {
'Image 1'
]);
});

it('should properly walk through nested levels of drop positioning', async () => {
render(<ReorderableTreeble defaultExpandedKeys={['1', '2']} />);
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');
});
});
Loading