Skip to content

Commit

Permalink
Add layout and orientation props to RAC ListBox (#4669)
Browse files Browse the repository at this point in the history
  • Loading branch information
devongovett committed Jun 26, 2023
1 parent 17e7299 commit 3c46c32
Show file tree
Hide file tree
Showing 11 changed files with 929 additions and 111 deletions.
124 changes: 102 additions & 22 deletions packages/@react-aria/dnd/src/ListDropTargetDelegate.ts
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) => {
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

1 comment on commit 3c46c32

@rspbot
Copy link

@rspbot rspbot commented on 3c46c32 Jun 26, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.