Skip to content

Commit 206ebd5

Browse files
devongovettreidbarberLFDanLu
authored
fix: Tree Dnd updates (#8229)
* Add onMove event and fix drop indicator a11y * use expand button as activate button * lint * fix onClick to check if event is within activateButton * add contains check to onKeyUp * Fix android talkback expand row when dragging * fix lint * fix item check in onClick to use contains * readd missing logic --------- Co-authored-by: Reid Barber <reid@reidbarber.com> Co-authored-by: Daniel Lu <dl1644@gmail.com>
1 parent f9ea4ca commit 206ebd5

File tree

10 files changed

+358
-93
lines changed

10 files changed

+358
-93
lines changed

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

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, Dr
1616
import {getDragModality, getTypes} from './utils';
1717
import {isVirtualClick, isVirtualPointerEvent} from '@react-aria/utils';
1818
import type {LocalizedStringFormatter} from '@internationalized/string';
19-
import {useEffect, useState} from 'react';
19+
import {RefObject, useEffect, useState} from 'react';
2020

2121
let dropTargets = new Map<Element, DropTarget>();
2222
let dropItems = new Map<Element, DroppableItem>();
@@ -32,7 +32,8 @@ interface DropTarget {
3232
onDropTargetEnter?: (target: DroppableCollectionTarget | null) => void,
3333
onDropActivate?: (e: DropActivateEvent, target: DroppableCollectionTarget | null) => void,
3434
onDrop?: (e: DropEvent, target: DroppableCollectionTarget | null) => void,
35-
onKeyDown?: (e: KeyboardEvent, dragTarget: DragTarget) => void
35+
onKeyDown?: (e: KeyboardEvent, dragTarget: DragTarget) => void,
36+
activateButtonRef?: RefObject<FocusableElement | null>
3637
}
3738

3839
export function registerDropTarget(target: DropTarget) {
@@ -47,7 +48,8 @@ export function registerDropTarget(target: DropTarget) {
4748
interface DroppableItem {
4849
element: FocusableElement,
4950
target: DroppableCollectionTarget,
50-
getDropOperation?: (types: Set<string>, allowedOperations: DropOperation[]) => DropOperation
51+
getDropOperation?: (types: Set<string>, allowedOperations: DropOperation[]) => DropOperation,
52+
activateButtonRef?: RefObject<FocusableElement | null>
5153
}
5254

5355
export function registerDropItem(item: DroppableItem) {
@@ -241,15 +243,26 @@ class DragSession {
241243
this.cancelEvent(e);
242244

243245
if (e.key === 'Enter') {
244-
if (e.altKey) {
245-
this.activate();
246+
if (e.altKey || this.getCurrentActivateButton()?.contains(e.target as Node)) {
247+
this.activate(this.currentDropTarget, this.currentDropItem);
246248
} else {
247249
this.drop();
248250
}
249251
}
250252
}
251253

254+
getCurrentActivateButton(): FocusableElement | null {
255+
return this.currentDropItem?.activateButtonRef?.current ?? this.currentDropTarget?.activateButtonRef?.current ?? null;
256+
}
257+
252258
onFocus(e: FocusEvent) {
259+
let activateButton = this.getCurrentActivateButton();
260+
if (e.target === activateButton) {
261+
// TODO: canceling this breaks the focus ring. Revisit when we support tabbing.
262+
this.cancelEvent(e);
263+
return;
264+
}
265+
253266
// Prevent focus events, except to the original drag target.
254267
if (e.target !== this.dragTarget.element) {
255268
this.cancelEvent(e);
@@ -265,6 +278,9 @@ class DragSession {
265278
this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement));
266279

267280
if (!dropTarget) {
281+
// if (e.target === activateButton) {
282+
// activateButton.focus();
283+
// }
268284
if (this.currentDropTarget) {
269285
this.currentDropTarget.element.focus();
270286
} else {
@@ -274,10 +290,18 @@ class DragSession {
274290
}
275291

276292
let item = dropItems.get(e.target as HTMLElement);
277-
this.setCurrentDropTarget(dropTarget, item);
293+
if (dropTarget) {
294+
this.setCurrentDropTarget(dropTarget, item);
295+
}
278296
}
279297

280298
onBlur(e: FocusEvent) {
299+
let activateButton = this.getCurrentActivateButton();
300+
if (e.relatedTarget === activateButton) {
301+
this.cancelEvent(e);
302+
return;
303+
}
304+
281305
if (e.target !== this.dragTarget.element) {
282306
this.cancelEvent(e);
283307
}
@@ -296,14 +320,21 @@ class DragSession {
296320
onClick(e: MouseEvent) {
297321
this.cancelEvent(e);
298322
if (isVirtualClick(e) || this.isVirtualClick) {
323+
let dropElements = dropItems.values();
324+
let item = [...dropElements].find(item => item.element === e.target as HTMLElement || item.activateButtonRef?.current?.contains(e.target as HTMLElement));
325+
let dropTarget = this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement));
326+
let activateButton = item?.activateButtonRef?.current ?? dropTarget?.activateButtonRef?.current;
327+
if (activateButton?.contains(e.target as HTMLElement) && dropTarget) {
328+
this.activate(dropTarget, item);
329+
return;
330+
}
331+
299332
if (e.target === this.dragTarget.element) {
300333
this.cancel();
301334
return;
302335
}
303336

304-
let dropTarget = this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement));
305337
if (dropTarget) {
306-
let item = dropItems.get(e.target as HTMLElement);
307338
this.setCurrentDropTarget(dropTarget, item);
308339
this.drop(item);
309340
}
@@ -319,7 +350,7 @@ class DragSession {
319350

320351
cancelEvent(e: Event) {
321352
// Allow focusin and focusout on the drag target so focus ring works properly.
322-
if ((e.type === 'focusin' || e.type === 'focusout') && e.target === this.dragTarget?.element) {
353+
if ((e.type === 'focusin' || e.type === 'focusout') && (e.target === this.dragTarget?.element || e.target === this.getCurrentActivateButton())) {
323354
return;
324355
}
325356

@@ -375,14 +406,23 @@ class DragSession {
375406

376407
this.restoreAriaHidden = ariaHideOutside([
377408
this.dragTarget.element,
378-
...validDropItems.map(item => item.element),
379-
...visibleDropTargets.map(target => target.element)
409+
...validDropItems.flatMap(item => item.activateButtonRef?.current ? [item.element, item.activateButtonRef?.current] : [item.element]),
410+
...visibleDropTargets.flatMap(target => target.activateButtonRef?.current ? [target.element, target.activateButtonRef?.current] : [target.element])
380411
]);
381412

382413
this.mutationObserver.observe(document.body, {subtree: true, attributes: true, attributeFilter: ['aria-hidden']});
383414
}
384415

385416
next() {
417+
// TODO: Allow tabbing to the activate button. Revisit once we fix the focus ring.
418+
// For now, the activate button is reachable by screen readers and ArrowLeft/ArrowRight
419+
// is usable specifically by Tree. Will need tabbing for other components.
420+
// let activateButton = this.getCurrentActivateButton();
421+
// if (activateButton && document.activeElement !== activateButton) {
422+
// activateButton.focus();
423+
// return;
424+
// }
425+
386426
if (!this.currentDropTarget) {
387427
this.setCurrentDropTarget(this.validDropTargets[0]);
388428
return;
@@ -409,6 +449,15 @@ class DragSession {
409449
}
410450

411451
previous() {
452+
// let activateButton = this.getCurrentActivateButton();
453+
// if (activateButton && document.activeElement === activateButton) {
454+
// let target = this.currentDropItem ?? this.currentDropTarget;
455+
// if (target) {
456+
// target.element.focus();
457+
// return;
458+
// }
459+
// }
460+
412461
if (!this.currentDropTarget) {
413462
this.setCurrentDropTarget(this.validDropTargets[this.validDropTargets.length - 1]);
414463
return;
@@ -487,7 +536,6 @@ class DragSession {
487536
if (this.currentDropTarget && typeof this.currentDropTarget.onDropTargetEnter === 'function') {
488537
this.currentDropTarget.onDropTargetEnter(item.target);
489538
}
490-
491539
item.element.focus();
492540
this.currentDropItem = item;
493541

@@ -576,14 +624,15 @@ class DragSession {
576624
announce(this.stringFormatter.format('dropComplete'));
577625
}
578626

579-
activate() {
580-
if (this.currentDropTarget && typeof this.currentDropTarget.onDropActivate === 'function') {
581-
let rect = this.currentDropTarget.element.getBoundingClientRect();
582-
this.currentDropTarget.onDropActivate({
627+
activate(dropTarget: DropTarget | null, dropItem: DroppableItem | null | undefined) {
628+
if (dropTarget && typeof dropTarget.onDropActivate === 'function') {
629+
let target = dropItem?.target ?? null;
630+
let rect = dropTarget.element.getBoundingClientRect();
631+
dropTarget.onDropActivate({
583632
type: 'dropactivate',
584633
x: rect.left + (rect.width / 2),
585634
y: rect.top + (rect.height / 2)
586-
}, null);
635+
}, target);
587636
}
588637
}
589638
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import * as DragManager from './DragManager';
1414
import {DroppableCollectionState} from '@react-stately/dnd';
15-
import {DropTarget, Key, RefObject} from '@react-types/shared';
15+
import {DropTarget, FocusableElement, Key, RefObject} from '@react-types/shared';
1616
import {getDroppableCollectionId} from './utils';
1717
import {HTMLAttributes} from 'react';
1818
// @ts-ignore
@@ -23,7 +23,9 @@ import {useLocalizedStringFormatter} from '@react-aria/i18n';
2323

2424
export interface DropIndicatorProps {
2525
/** The drop target that the drop indicator represents. */
26-
target: DropTarget
26+
target: DropTarget,
27+
/** The ref to the activate button. */
28+
activateButtonRef?: RefObject<FocusableElement | null>
2729
}
2830

2931
export interface DropIndicatorAria {

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
DropTargetDelegate,
3131
Key,
3232
KeyboardDelegate,
33-
KeyboardEvents,
3433
Node,
3534
RefObject
3635
} from '@react-types/shared';
@@ -48,7 +47,8 @@ export interface DroppableCollectionOptions extends DroppableCollectionProps {
4847
keyboardDelegate: KeyboardDelegate,
4948
/** A delegate object that provides drop targets for pointer coordinates within the collection. */
5049
dropTargetDelegate: DropTargetDelegate,
51-
onKeyDown?: KeyboardEvents['onKeyDown']
50+
/** A custom keyboard event handler for drop targets. */
51+
onKeyDown?: (e: KeyboardEvent) => void
5252
}
5353

5454
export interface DroppableCollectionResult {
@@ -94,6 +94,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
9494
onRootDrop,
9595
onItemDrop,
9696
onReorder,
97+
onMove,
9798
acceptedDragTypes = 'all',
9899
shouldAcceptItemDrop
99100
} = localState.props;
@@ -139,6 +140,10 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
139140
await onItemDrop({items: filteredItems, dropOperation, isInternal, target});
140141
}
141142

143+
if (onMove && isInternal) {
144+
await onMove({keys: draggingKeys, dropOperation, target});
145+
}
146+
142147
if (target.dropPosition !== 'on') {
143148
if (!isInternal && onInsert) {
144149
await onInsert({items: filteredItems, dropOperation, target});
@@ -590,17 +595,17 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
590595
onDropTargetEnter(target) {
591596
localState.state.setTarget(target);
592597
},
593-
onDropActivate(e) {
598+
onDropActivate(e, target) {
594599
if (
595-
localState.state.target?.type === 'item' &&
596-
localState.state.target?.dropPosition === 'on' &&
600+
target?.type === 'item' &&
601+
target?.dropPosition === 'on' &&
597602
typeof localState.props.onDropActivate === 'function'
598603
) {
599604
localState.props.onDropActivate({
600605
type: 'dropactivate',
601606
x: e.x, // todo
602607
y: e.y,
603-
target: localState.state.target
608+
target
604609
});
605610
}
606611
},
@@ -750,7 +755,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
750755
break;
751756
}
752757
}
753-
localState.props.onKeyDown?.(e as any);
758+
localState.props.onKeyDown?.(e);
754759
}
755760
});
756761
}, [localState, ref, onDrop, direction]);

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212

1313
import * as DragManager from './DragManager';
1414
import {DroppableCollectionState} from '@react-stately/dnd';
15-
import {DropTarget, RefObject} from '@react-types/shared';
15+
import {DropTarget, FocusableElement, RefObject} from '@react-types/shared';
1616
import {getDroppableCollectionRef, getTypes, globalDndState, isInternalDropOperation} from './utils';
1717
import {HTMLAttributes, useEffect} from 'react';
1818
import {useVirtualDrop} from './useVirtualDrop';
1919

2020
export interface DroppableItemOptions {
2121
/** The drop target represented by the item. */
22-
target: DropTarget
22+
target: DropTarget,
23+
/** The ref to the activate button. */
24+
activateButtonRef?: RefObject<FocusableElement | null>
2325
}
2426

2527
export interface DroppableItemResult {
@@ -50,10 +52,11 @@ export function useDroppableItem(options: DroppableItemOptions, state: Droppable
5052
isInternal,
5153
draggingKeys
5254
});
53-
}
55+
},
56+
activateButtonRef: options.activateButtonRef
5457
});
5558
}
56-
}, [ref, options.target, state, droppableCollectionRef]);
59+
}, [ref, options.target, state, droppableCollectionRef, options.activateButtonRef]);
5760

5861
let dragSession = DragManager.useDragSession();
5962
let {draggingKeys} = globalDndState;

packages/@react-stately/dnd/src/useDroppableCollectionState.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function useDroppableCollectionState(props: DroppableCollectionStateOptio
5959
onRootDrop,
6060
onItemDrop,
6161
onReorder,
62+
onMove,
6263
shouldAcceptItemDrop,
6364
collection,
6465
selectionManager,
@@ -95,13 +96,32 @@ export function useDroppableCollectionState(props: DroppableCollectionStateOptio
9596

9697
if (acceptedDragTypes === 'all' || acceptedDragTypes.some(type => types.has(type))) {
9798
let isValidInsert = onInsert && target.type === 'item' && !isInternal && (target.dropPosition === 'before' || target.dropPosition === 'after');
98-
let isValidReorder = onReorder && target.type === 'item' && isInternal && (target.dropPosition === 'before' || target.dropPosition === 'after');
99+
let isValidReorder = onReorder
100+
&& target.type === 'item'
101+
&& isInternal
102+
&& (target.dropPosition === 'before' || target.dropPosition === 'after')
103+
&& isDraggingWithinParent(collection, target, draggingKeys);
104+
105+
let isItemDropAllowed = target.type !== 'item'
106+
|| target.dropPosition !== 'on'
107+
|| (!shouldAcceptItemDrop || shouldAcceptItemDrop(target, types));
108+
109+
let isValidMove = onMove
110+
&& target.type === 'item'
111+
&& isInternal
112+
&& isItemDropAllowed;
113+
99114
// Feedback was that internal root drop was weird so preventing that from happening
100115
let isValidRootDrop = onRootDrop && target.type === 'root' && !isInternal;
116+
101117
// Automatically prevent items (i.e. folders) from being dropped on themselves.
102-
let isValidOnItemDrop = onItemDrop && target.type === 'item' && target.dropPosition === 'on' && !(isInternal && target.key != null && draggingKeys.has(target.key)) && (!shouldAcceptItemDrop || shouldAcceptItemDrop(target, types));
118+
let isValidOnItemDrop = onItemDrop
119+
&& target.type === 'item'
120+
&& target.dropPosition === 'on'
121+
&& !(isInternal && target.key != null && draggingKeys.has(target.key))
122+
&& isItemDropAllowed;
103123

104-
if (onDrop || isValidInsert || isValidReorder || isValidRootDrop || isValidOnItemDrop) {
124+
if (onDrop || isValidInsert || isValidReorder || isValidMove || isValidRootDrop || isValidOnItemDrop) {
105125
if (getDropOperation) {
106126
return getDropOperation(target, types, allowedOperations);
107127
} else {
@@ -111,7 +131,7 @@ export function useDroppableCollectionState(props: DroppableCollectionStateOptio
111131
}
112132

113133
return 'cancel';
114-
}, [isDisabled, acceptedDragTypes, getDropOperation, onInsert, onRootDrop, onItemDrop, shouldAcceptItemDrop, onReorder, onDrop]);
134+
}, [isDisabled, collection, acceptedDragTypes, getDropOperation, onInsert, onRootDrop, onItemDrop, shouldAcceptItemDrop, onReorder, onMove, onDrop]);
115135

116136
return {
117137
collection,
@@ -187,3 +207,16 @@ function isEqualDropTarget(a?: DropTarget | null, b?: DropTarget | null) {
187207
return b?.type === 'item' && b?.key === a.key && b?.dropPosition === a.dropPosition;
188208
}
189209
}
210+
211+
function isDraggingWithinParent(collection: Collection<Node<unknown>>, target: ItemDropTarget, draggingKeys: Set<Key>) {
212+
let targetNode = collection.getItem(target.key);
213+
214+
for (let key of draggingKeys) {
215+
let node = collection.getItem(key);
216+
if (node?.parentKey !== targetNode?.parentKey) {
217+
return false;
218+
}
219+
}
220+
221+
return true;
222+
}

0 commit comments

Comments
 (0)