Skip to content

Commit bed1793

Browse files
authored
fix: Improve tree dnd keyboard navigation (#8271)
* fix: Improve tree dnd keyboard navigation * Move activate button to be inside drop indicator row * yarn why you do this?
1 parent 77b3442 commit bed1793

File tree

11 files changed

+731
-150
lines changed

11 files changed

+731
-150
lines changed

packages/@react-aria/dnd/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@react-aria/live-announcer": "^3.4.2",
2929
"@react-aria/overlays": "^3.27.1",
3030
"@react-aria/utils": "^3.29.0",
31+
"@react-stately/collections": "^3.12.4",
3132
"@react-stately/dnd": "^3.5.4",
3233
"@react-types/button": "^3.12.1",
3334
"@react-types/shared": "^3.29.1",
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import {Collection, DropTarget, Key, KeyboardDelegate, Node} from '@react-types/shared';
2+
import {getChildNodes} from '@react-stately/collections';
3+
4+
export function navigate(
5+
keyboardDelegate: KeyboardDelegate,
6+
collection: Collection<Node<unknown>>,
7+
target: DropTarget | null | undefined,
8+
direction: 'left' | 'right' | 'up' | 'down',
9+
rtl = false,
10+
wrap = false
11+
): DropTarget | null {
12+
switch (direction) {
13+
case 'left':
14+
return rtl
15+
? nextDropTarget(keyboardDelegate, collection, target, wrap, 'left')
16+
: previousDropTarget(keyboardDelegate, collection, target, wrap, 'left');
17+
case 'right':
18+
return rtl
19+
? previousDropTarget(keyboardDelegate, collection, target, wrap, 'right')
20+
: nextDropTarget(keyboardDelegate, collection, target, wrap, 'right');
21+
case 'up':
22+
return previousDropTarget(keyboardDelegate, collection, target, wrap);
23+
case 'down':
24+
return nextDropTarget(keyboardDelegate, collection, target, wrap);
25+
}
26+
}
27+
28+
function nextDropTarget(
29+
keyboardDelegate: KeyboardDelegate,
30+
collection: Collection<Node<unknown>>,
31+
target: DropTarget | null | undefined,
32+
wrap = false,
33+
horizontal: 'left' | 'right' | null = null
34+
): DropTarget | null {
35+
if (!target) {
36+
return {
37+
type: 'root'
38+
};
39+
}
40+
41+
if (target.type === 'root') {
42+
let nextKey = keyboardDelegate.getFirstKey?.() ?? null;
43+
if (nextKey != null) {
44+
return {
45+
type: 'item',
46+
key: nextKey,
47+
dropPosition: 'before'
48+
};
49+
}
50+
51+
return null;
52+
}
53+
54+
if (target.type === 'item') {
55+
let nextKey: Key | null | undefined = null;
56+
if (horizontal) {
57+
nextKey = horizontal === 'right' ? keyboardDelegate.getKeyRightOf?.(target.key) : keyboardDelegate.getKeyLeftOf?.(target.key);
58+
} else {
59+
nextKey = keyboardDelegate.getKeyBelow?.(target.key);
60+
}
61+
let nextCollectionKey = collection.getKeyAfter(target.key);
62+
63+
// If the keyboard delegate did not move to the next key in the collection,
64+
// jump to that key with the same drop position. Otherwise, try the other
65+
// drop positions on the current key first.
66+
if (nextKey != null && nextKey !== nextCollectionKey) {
67+
return {
68+
type: 'item',
69+
key: nextKey,
70+
dropPosition: target.dropPosition
71+
};
72+
}
73+
74+
switch (target.dropPosition) {
75+
case 'before': {
76+
return {
77+
type: 'item',
78+
key: target.key,
79+
dropPosition: 'on'
80+
};
81+
}
82+
case 'on': {
83+
// If there are nested items, traverse to them prior to the "after" position of this target.
84+
// If the next key is on the same level, then its "before" position is equivalent to this item's "after" position.
85+
let targetNode = collection.getItem(target.key);
86+
let nextNode = nextKey != null ? collection.getItem(nextKey) : null;
87+
if (targetNode && nextNode && nextNode.level >= targetNode.level) {
88+
return {
89+
type: 'item',
90+
key: nextNode.key,
91+
dropPosition: 'before'
92+
};
93+
}
94+
95+
return {
96+
type: 'item',
97+
key: target.key,
98+
dropPosition: 'after'
99+
};
100+
}
101+
case 'after': {
102+
// If this is the last sibling in a level, traverse to the parent.
103+
let targetNode = collection.getItem(target.key);
104+
if (targetNode && targetNode.nextKey == null && targetNode.parentKey != null) {
105+
// If the parent item has an item after it, use the "before" position.
106+
let parentNode = collection.getItem(targetNode.parentKey);
107+
if (parentNode?.nextKey != null) {
108+
return {
109+
type: 'item',
110+
key: parentNode.nextKey,
111+
dropPosition: 'before'
112+
};
113+
}
114+
115+
if (parentNode) {
116+
return {
117+
type: 'item',
118+
key: parentNode.key,
119+
dropPosition: 'after'
120+
};
121+
}
122+
}
123+
124+
if (targetNode?.nextKey != null) {
125+
return {
126+
type: 'item',
127+
key: targetNode.nextKey,
128+
dropPosition: 'on'
129+
};
130+
}
131+
}
132+
}
133+
}
134+
135+
if (wrap) {
136+
return {
137+
type: 'root'
138+
};
139+
}
140+
141+
return null;
142+
}
143+
144+
function previousDropTarget(
145+
keyboardDelegate: KeyboardDelegate,
146+
collection: Collection<Node<unknown>>,
147+
target: DropTarget | null | undefined,
148+
wrap = false,
149+
horizontal: 'left' | 'right' | null = null
150+
): DropTarget | null {
151+
// Start after the last root-level item.
152+
if (!target || (wrap && target.type === 'root')) {
153+
// Keyboard delegate gets the deepest item but we want the shallowest.
154+
let prevKey: Key | null = null;
155+
let lastKey = keyboardDelegate.getLastKey?.();
156+
while (lastKey != null) {
157+
prevKey = lastKey;
158+
let node = collection.getItem(lastKey);
159+
lastKey = node?.parentKey;
160+
}
161+
162+
if (prevKey != null) {
163+
return {
164+
type: 'item',
165+
key: prevKey,
166+
dropPosition: 'after'
167+
};
168+
}
169+
170+
return null;
171+
}
172+
173+
if (target.type === 'item') {
174+
let prevKey: Key | null | undefined = null;
175+
if (horizontal) {
176+
prevKey = horizontal === 'left' ? keyboardDelegate.getKeyLeftOf?.(target.key) : keyboardDelegate.getKeyRightOf?.(target.key);
177+
} else {
178+
prevKey = keyboardDelegate.getKeyAbove?.(target.key);
179+
}
180+
let prevCollectionKey = collection.getKeyBefore(target.key);
181+
182+
// If the keyboard delegate did not move to the next key in the collection,
183+
// jump to that key with the same drop position. Otherwise, try the other
184+
// drop positions on the current key first.
185+
if (prevKey != null && prevKey !== prevCollectionKey) {
186+
return {
187+
type: 'item',
188+
key: prevKey,
189+
dropPosition: target.dropPosition
190+
};
191+
}
192+
193+
switch (target.dropPosition) {
194+
case 'before': {
195+
// Move after the last child of the previous item.
196+
let targetNode = collection.getItem(target.key);
197+
if (targetNode && targetNode.prevKey != null) {
198+
let lastChild = getLastChild(collection, targetNode.prevKey);
199+
if (lastChild) {
200+
return lastChild;
201+
}
202+
}
203+
204+
if (prevKey != null) {
205+
return {
206+
type: 'item',
207+
key: prevKey,
208+
dropPosition: 'on'
209+
};
210+
}
211+
212+
return {
213+
type: 'root'
214+
};
215+
}
216+
case 'on': {
217+
return {
218+
type: 'item',
219+
key: target.key,
220+
dropPosition: 'before'
221+
};
222+
}
223+
case 'after': {
224+
// Move after the last child of this item.
225+
let lastChild = getLastChild(collection, target.key);
226+
if (lastChild) {
227+
return lastChild;
228+
}
229+
230+
return {
231+
type: 'item',
232+
key: target.key,
233+
dropPosition: 'on'
234+
};
235+
}
236+
}
237+
}
238+
239+
if (target.type !== 'root') {
240+
return {
241+
type: 'root'
242+
};
243+
}
244+
245+
return null;
246+
}
247+
248+
function getLastChild(collection: Collection<Node<unknown>>, key: Key): DropTarget | null {
249+
// getChildNodes still returns child tree items even when the item is collapsed.
250+
// Checking if the next item has a greater level is a silly way to determine if the item is expanded.
251+
let targetNode = collection.getItem(key);
252+
let nextKey = collection.getKeyAfter(key);
253+
let nextNode = nextKey != null ? collection.getItem(nextKey) : null;
254+
if (targetNode && nextNode && nextNode.level > targetNode.level) {
255+
let children = getChildNodes(targetNode, collection);
256+
let lastChild: Node<unknown> | null = null;
257+
for (let child of children) {
258+
if (child.type === 'item') {
259+
lastChild = child;
260+
}
261+
}
262+
263+
if (lastChild) {
264+
return {
265+
type: 'item',
266+
key: lastChild.key,
267+
dropPosition: 'after'
268+
};
269+
}
270+
}
271+
272+
return null;
273+
}

packages/@react-aria/dnd/src/useDropIndicator.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,28 +71,32 @@ export function useDropIndicator(props: DropIndicatorProps, state: DroppableColl
7171
} else {
7272
let before: Key | null | undefined;
7373
let after: Key | null | undefined;
74-
if (collection.getFirstKey() === target.key && target.dropPosition === 'before') {
75-
before = null;
74+
if (target.dropPosition === 'before') {
75+
let prevKey = collection.getItem(target.key)?.prevKey;
76+
let prevNode = prevKey != null ? collection.getItem(prevKey) : null;
77+
before = prevNode?.type === 'item' ? prevNode.key : null;
7678
} else {
77-
before = target.dropPosition === 'before' ? collection.getKeyBefore(target.key) : target.key;
79+
before = target.key;
7880
}
7981

80-
if (collection.getLastKey() === target.key && target.dropPosition === 'after') {
81-
after = null;
82+
if (target.dropPosition === 'after') {
83+
let nextKey = collection.getItem(target.key)?.nextKey;
84+
let nextNode = nextKey != null ? collection.getItem(nextKey) : null;
85+
after = nextNode?.type === 'item' ? nextNode.key : null;
8286
} else {
83-
after = target.dropPosition === 'after' ? collection.getKeyAfter(target.key) : target.key;
87+
after = target.key;
8488
}
8589

86-
if (before && after) {
90+
if (before != null && after != null) {
8791
label = stringFormatter.format('insertBetween', {
8892
beforeItemText: getText(before),
8993
afterItemText: getText(after)
9094
});
91-
} else if (before) {
95+
} else if (before != null) {
9296
label = stringFormatter.format('insertAfter', {
9397
itemText: getText(before)
9498
});
95-
} else if (after) {
99+
} else if (after != null) {
96100
label = stringFormatter.format('insertBefore', {
97101
itemText: getText(after)
98102
});

0 commit comments

Comments
 (0)