Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
124 changes: 102 additions & 22 deletions packages/@react-aria/dnd/src/ListDropTargetDelegate.ts
Copy link
Member

Choose a reason for hiding this comment

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

As for your question about if some of this should be split out into a separate GridKeyboardDelegate, IMO it is fine to stay in here since it isn't a true grid movement pattern if that makes sense. The contents of a layout="grid" are "wrapping" in a specific direction rather than being truly laid out in a grid layout and thus left/right or up/down will move focus onto the next row/column depending on the orientation.

Original file line number Diff line number Diff line change
@@ -1,13 +1,75 @@
import {Collection, DropTarget, DropTargetDelegate, Node} from '@react-types/shared';
import {Collection, Direction, DropTarget, DropTargetDelegate, Node, Orientation} from '@react-types/shared';
import {RefObject} from 'react';

interface ListDropTargetDelegateOptions {
/**
* Whether the items are arranged in a stack or grid.
* @default 'stack'
*/
layout?: 'stack' | 'grid',
/**
* The primary orientation of the items. Usually this is the
* direction that the collection scrolls.
* @default 'vertical'
*/
orientation?: Orientation,
/**
* The horizontal layout direction.
* @default 'ltr'
*/
direction?: Direction
}

// Terms used in the below code:
// * "Primary" – The main layout direction. For stacks, this is the direction
// that the stack is arranged in (e.g. horizontal or vertical).
// For grids, this is the main scroll direction.
// * "Secondary" – The secondary layout direction. For stacks, there is no secondary
// layout direction. For grids, this is the opposite of the primary direction.
// * "Flow" – The flow direction of the items. For stacks, this is the the primary
// direction. For grids, it is the secondary direction.

export class ListDropTargetDelegate implements DropTargetDelegate {
private collection: Collection<Node<unknown>>;
private ref: RefObject<HTMLElement>;
private layout: 'stack' | 'grid';
private orientation: Orientation;
private direction: Direction;

constructor(collection: Collection<Node<unknown>>, ref: RefObject<HTMLElement>) {
constructor(collection: Collection<Node<unknown>>, ref: RefObject<HTMLElement>, options?: ListDropTargetDelegateOptions) {
this.collection = collection;
this.ref = ref;
this.layout = options?.layout || 'stack';
this.orientation = options?.orientation || 'vertical';
this.direction = options?.direction || 'ltr';
}

private getPrimaryStart(rect: DOMRect) {
return this.orientation === 'horizontal' ? rect.left : rect.top;
}

private getPrimaryEnd(rect: DOMRect) {
return this.orientation === 'horizontal' ? rect.right : rect.bottom;
}

private getSecondaryStart(rect: DOMRect) {
return this.orientation === 'horizontal' ? rect.top : rect.left;
}

private getSecondaryEnd(rect: DOMRect) {
return this.orientation === 'horizontal' ? rect.bottom : rect.right;
}

private getFlowStart(rect: DOMRect) {
return this.layout === 'stack' ? this.getPrimaryStart(rect) : this.getSecondaryStart(rect);
}

private getFlowEnd(rect: DOMRect) {
return this.layout === 'stack' ? this.getPrimaryEnd(rect) : this.getSecondaryEnd(rect);
}

private getFlowSize(rect: DOMRect) {
return this.getFlowEnd(rect) - this.getFlowStart(rect);
}

getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget {
Expand All @@ -16,8 +78,15 @@ export class ListDropTargetDelegate implements DropTargetDelegate {
}

let rect = this.ref.current.getBoundingClientRect();
x += rect.x;
y += rect.y;
let primary = this.orientation === 'horizontal' ? x : y;
let secondary = this.orientation === 'horizontal' ? y : x;
primary += this.getPrimaryStart(rect);
secondary += this.getSecondaryStart(rect);

let flow = this.layout === 'stack' ? primary : secondary;
let isPrimaryRTL = this.orientation === 'horizontal' && this.direction === 'rtl';
let isSecondaryRTL = this.layout === 'grid' && this.orientation === 'vertical' && this.direction === 'rtl';
let isFlowRTL = this.layout === 'stack' ? isPrimaryRTL : isSecondaryRTL;

let elements = this.ref.current.querySelectorAll('[data-key]');
let elementMap = new Map<string, HTMLElement>();
Expand All @@ -35,11 +104,22 @@ export class ListDropTargetDelegate implements DropTargetDelegate {
let item = items[mid];
let element = elementMap.get(String(item.key));
let rect = element.getBoundingClientRect();
let update = (isGreater: boolean) => {
Copy link
Member

Choose a reason for hiding this comment

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

Mind adding a comment or two to this code block (aka the stuff within the chain of if statements)? At a glance I think this is narrowing down which item in the collection to consider the drop target with respect to LTR/RTL + the primary/secondary directions but I can't easily tell if the else at the end only happens once that is determined.

if (isGreater) {
low = mid + 1;
} else {
high = mid;
}
};

if (y < rect.top) {
high = mid;
} else if (y > rect.bottom) {
low = mid + 1;
if (primary < this.getPrimaryStart(rect)) {
update(isPrimaryRTL);
} else if (primary > this.getPrimaryEnd(rect)) {
update(!isPrimaryRTL);
} else if (secondary < this.getSecondaryStart(rect)) {
update(isSecondaryRTL);
} else if (secondary > this.getSecondaryEnd(rect)) {
update(!isSecondaryRTL);
} else {
let target: DropTarget = {
type: 'item',
Expand All @@ -49,19 +129,19 @@ export class ListDropTargetDelegate implements DropTargetDelegate {

if (isValidDropTarget(target)) {
// Otherwise, if dropping on the item is accepted, try the before/after positions if within 5px
// of the top or bottom of the item.
if (y <= rect.top + 5 && isValidDropTarget({...target, dropPosition: 'before'})) {
target.dropPosition = 'before';
} else if (y >= rect.bottom - 5 && isValidDropTarget({...target, dropPosition: 'after'})) {
target.dropPosition = 'after';
// of the start or end of the item.
if (flow <= this.getFlowStart(rect) + 5 && isValidDropTarget({...target, dropPosition: 'before'})) {
target.dropPosition = isFlowRTL ? 'after' : 'before';
} else if (flow >= this.getFlowEnd(rect) - 5 && isValidDropTarget({...target, dropPosition: 'after'})) {
target.dropPosition = isFlowRTL ? 'before' : 'after';
}
} else {
// If dropping on the item isn't accepted, try the target before or after depending on the y position.
let midY = rect.top + rect.height / 2;
if (y <= midY && isValidDropTarget({...target, dropPosition: 'before'})) {
target.dropPosition = 'before';
} else if (y >= midY && isValidDropTarget({...target, dropPosition: 'after'})) {
target.dropPosition = 'after';
// If dropping on the item isn't accepted, try the target before or after depending on the position.
let mid = this.getFlowStart(rect) + this.getFlowSize(rect) / 2;
if (flow <= mid && isValidDropTarget({...target, dropPosition: 'before'})) {
target.dropPosition = isFlowRTL ? 'after' : 'before';
} else if (flow >= mid && isValidDropTarget({...target, dropPosition: 'after'})) {
target.dropPosition = isFlowRTL ? 'before' : 'after';
}
}

Expand All @@ -73,18 +153,18 @@ export class ListDropTargetDelegate implements DropTargetDelegate {
let element = elementMap.get(String(item.key));
rect = element.getBoundingClientRect();

if (Math.abs(y - rect.top) < Math.abs(y - rect.bottom)) {
if (primary < this.getPrimaryStart(rect) || Math.abs(flow - this.getFlowStart(rect)) < Math.abs(flow - this.getFlowEnd(rect))) {
return {
type: 'item',
key: item.key,
dropPosition: 'before'
dropPosition: isFlowRTL ? 'after' : 'before'
};
}

return {
type: 'item',
key: item.key,
dropPosition: 'after'
dropPosition: isFlowRTL ? 'before' : 'after'
};
}
}
115 changes: 78 additions & 37 deletions packages/@react-aria/dnd/src/useDroppableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {mergeProps, useId, useLayoutEffect} from '@react-aria/utils';
import {setInteractionModality} from '@react-aria/interactions';
import {useAutoScroll} from './useAutoScroll';
import {useDrop} from './useDrop';
import {useLocale} from '@react-aria/i18n';

export interface DroppableCollectionOptions extends DroppableCollectionProps {
/** A delegate object that implements behavior for keyboard focus movement. */
Expand All @@ -59,6 +60,7 @@ interface DroppingState {
}

const DROP_POSITIONS: DropPosition[] = ['before', 'on', 'after'];
const DROP_POSITIONS_RTL: DropPosition[] = ['after', 'on', 'before'];

/**
* Handles drop interactions for a collection component, with support for traditional mouse and touch
Expand Down Expand Up @@ -315,35 +317,48 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
}
});

let {direction} = useLocale();
useEffect(() => {
let getNextTarget = (target: DropTarget, wrap = true): DropTarget => {
let getNextTarget = (target: DropTarget, wrap = true, horizontal = false): DropTarget => {
if (!target) {
return {
type: 'root'
};
}

let {keyboardDelegate} = localState.props;
let nextKey = target.type === 'item'
? keyboardDelegate.getKeyBelow(target.key)
: keyboardDelegate.getFirstKey();
let dropPosition: DropPosition = 'before';
let nextKey: Key;
if (target?.type === 'item') {
nextKey = horizontal ? keyboardDelegate.getKeyRightOf(target.key) : keyboardDelegate.getKeyBelow(target.key);
} else {
nextKey = horizontal && direction === 'rtl' ? keyboardDelegate.getLastKey() : keyboardDelegate.getFirstKey();
}
let dropPositions = horizontal && direction === 'rtl' ? DROP_POSITIONS_RTL : DROP_POSITIONS;
let dropPosition: DropPosition = dropPositions[0];

if (target.type === 'item') {
let positionIndex = DROP_POSITIONS.indexOf(target.dropPosition);
let nextDropPosition = DROP_POSITIONS[positionIndex + 1];
if (positionIndex < DROP_POSITIONS.length - 1 && !(nextDropPosition === 'after' && nextKey != null)) {
return {
type: 'item',
key: target.key,
dropPosition: nextDropPosition
};
}
// If the the keyboard delegate returned the next key in the collection,
// first try the other positions in the current key. Otherwise (e.g. in a grid layout),
// jump to the same drop position in the new key.
let nextCollectionKey = horizontal && direction === 'rtl' ? localState.state.collection.getKeyBefore(target.key) : localState.state.collection.getKeyAfter(target.key);
if (nextKey == null || nextKey === nextCollectionKey) {
let positionIndex = dropPositions.indexOf(target.dropPosition);
let nextDropPosition = dropPositions[positionIndex + 1];
if (positionIndex < dropPositions.length - 1 && !(nextDropPosition === dropPositions[2] && nextKey != null)) {
return {
type: 'item',
key: target.key,
dropPosition: nextDropPosition
};
}

// If the last drop position was 'after', then 'before' on the next key is equivalent.
// Switch to 'on' instead.
if (target.dropPosition === 'after') {
dropPosition = 'on';
// If the last drop position was 'after', then 'before' on the next key is equivalent.
// Switch to 'on' instead.
if (target.dropPosition === dropPositions[2]) {
dropPosition = 'on';
}
} else {
dropPosition = target.dropPosition;
}
}

Expand All @@ -364,28 +379,40 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
};
};

let getPreviousTarget = (target: DropTarget, wrap = true): DropTarget => {
let getPreviousTarget = (target: DropTarget, wrap = true, horizontal = false): DropTarget => {
let {keyboardDelegate} = localState.props;
let nextKey = target?.type === 'item'
? keyboardDelegate.getKeyAbove(target.key)
: keyboardDelegate.getLastKey();
let dropPosition: DropPosition = !target || target.type === 'root' ? 'after' : 'on';
let nextKey: Key;
if (target?.type === 'item') {
nextKey = horizontal ? keyboardDelegate.getKeyLeftOf(target.key) : keyboardDelegate.getKeyAbove(target.key);
} else {
nextKey = horizontal && direction === 'rtl' ? keyboardDelegate.getFirstKey() : keyboardDelegate.getLastKey();
}
let dropPositions = horizontal && direction === 'rtl' ? DROP_POSITIONS_RTL : DROP_POSITIONS;
let dropPosition: DropPosition = !target || target.type === 'root' ? dropPositions[2] : 'on';

if (target?.type === 'item') {
let positionIndex = DROP_POSITIONS.indexOf(target.dropPosition);
let nextDropPosition = DROP_POSITIONS[positionIndex - 1];
if (positionIndex > 0 && nextDropPosition !== 'after') {
return {
type: 'item',
key: target.key,
dropPosition: nextDropPosition
};
}
// If the the keyboard delegate returned the previous key in the collection,
// first try the other positions in the current key. Otherwise (e.g. in a grid layout),
// jump to the same drop position in the new key.
let prevCollectionKey = horizontal && direction === 'rtl' ? localState.state.collection.getKeyAfter(target.key) : localState.state.collection.getKeyBefore(target.key);
if (nextKey == null || nextKey === prevCollectionKey) {
let positionIndex = dropPositions.indexOf(target.dropPosition);
let nextDropPosition = dropPositions[positionIndex - 1];
if (positionIndex > 0 && nextDropPosition !== dropPositions[2]) {
return {
type: 'item',
key: target.key,
dropPosition: nextDropPosition
};
}

// If the last drop position was 'before', then 'after' on the previous key is equivalent.
// Switch to 'on' instead.
if (target.dropPosition === 'before') {
dropPosition = 'on';
// If the last drop position was 'before', then 'after' on the previous key is equivalent.
// Switch to 'on' instead.
if (target.dropPosition === dropPositions[0]) {
dropPosition = 'on';
}
} else {
dropPosition = target.dropPosition;
}
}

Expand Down Expand Up @@ -553,6 +580,20 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
}
break;
}
case 'ArrowLeft': {
if (keyboardDelegate.getKeyLeftOf) {
let target = nextValidTarget(localState.state.target, types, drag.allowedDropOperations, (target, wrap) => getPreviousTarget(target, wrap, true));
localState.state.setTarget(target);
}
break;
}
case 'ArrowRight': {
if (keyboardDelegate.getKeyRightOf) {
let target = nextValidTarget(localState.state.target, types, drag.allowedDropOperations, (target, wrap) => getNextTarget(target, wrap, true));
localState.state.setTarget(target);
}
break;
}
case 'Home': {
if (keyboardDelegate.getFirstKey) {
let target = nextValidTarget(null, types, drag.allowedDropOperations, getNextTarget);
Expand Down Expand Up @@ -654,7 +695,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
}
}
});
}, [localState, ref, onDrop]);
}, [localState, ref, onDrop, direction]);

let id = useId();
droppableCollectionMap.set(state, {id, ref});
Expand Down
Loading