Skip to content

Commit 3c46c32

Browse files
authored
Add layout and orientation props to RAC ListBox (#4669)
1 parent 17e7299 commit 3c46c32

File tree

11 files changed

+929
-111
lines changed

11 files changed

+929
-111
lines changed
Lines changed: 102 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,75 @@
1-
import {Collection, DropTarget, DropTargetDelegate, Node} from '@react-types/shared';
1+
import {Collection, Direction, DropTarget, DropTargetDelegate, Node, Orientation} from '@react-types/shared';
22
import {RefObject} from 'react';
33

4+
interface ListDropTargetDelegateOptions {
5+
/**
6+
* Whether the items are arranged in a stack or grid.
7+
* @default 'stack'
8+
*/
9+
layout?: 'stack' | 'grid',
10+
/**
11+
* The primary orientation of the items. Usually this is the
12+
* direction that the collection scrolls.
13+
* @default 'vertical'
14+
*/
15+
orientation?: Orientation,
16+
/**
17+
* The horizontal layout direction.
18+
* @default 'ltr'
19+
*/
20+
direction?: Direction
21+
}
22+
23+
// Terms used in the below code:
24+
// * "Primary" – The main layout direction. For stacks, this is the direction
25+
// that the stack is arranged in (e.g. horizontal or vertical).
26+
// For grids, this is the main scroll direction.
27+
// * "Secondary" – The secondary layout direction. For stacks, there is no secondary
28+
// layout direction. For grids, this is the opposite of the primary direction.
29+
// * "Flow" – The flow direction of the items. For stacks, this is the the primary
30+
// direction. For grids, it is the secondary direction.
31+
432
export class ListDropTargetDelegate implements DropTargetDelegate {
533
private collection: Collection<Node<unknown>>;
634
private ref: RefObject<HTMLElement>;
35+
private layout: 'stack' | 'grid';
36+
private orientation: Orientation;
37+
private direction: Direction;
738

8-
constructor(collection: Collection<Node<unknown>>, ref: RefObject<HTMLElement>) {
39+
constructor(collection: Collection<Node<unknown>>, ref: RefObject<HTMLElement>, options?: ListDropTargetDelegateOptions) {
940
this.collection = collection;
1041
this.ref = ref;
42+
this.layout = options?.layout || 'stack';
43+
this.orientation = options?.orientation || 'vertical';
44+
this.direction = options?.direction || 'ltr';
45+
}
46+
47+
private getPrimaryStart(rect: DOMRect) {
48+
return this.orientation === 'horizontal' ? rect.left : rect.top;
49+
}
50+
51+
private getPrimaryEnd(rect: DOMRect) {
52+
return this.orientation === 'horizontal' ? rect.right : rect.bottom;
53+
}
54+
55+
private getSecondaryStart(rect: DOMRect) {
56+
return this.orientation === 'horizontal' ? rect.top : rect.left;
57+
}
58+
59+
private getSecondaryEnd(rect: DOMRect) {
60+
return this.orientation === 'horizontal' ? rect.bottom : rect.right;
61+
}
62+
63+
private getFlowStart(rect: DOMRect) {
64+
return this.layout === 'stack' ? this.getPrimaryStart(rect) : this.getSecondaryStart(rect);
65+
}
66+
67+
private getFlowEnd(rect: DOMRect) {
68+
return this.layout === 'stack' ? this.getPrimaryEnd(rect) : this.getSecondaryEnd(rect);
69+
}
70+
71+
private getFlowSize(rect: DOMRect) {
72+
return this.getFlowEnd(rect) - this.getFlowStart(rect);
1173
}
1274

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

1880
let rect = this.ref.current.getBoundingClientRect();
19-
x += rect.x;
20-
y += rect.y;
81+
let primary = this.orientation === 'horizontal' ? x : y;
82+
let secondary = this.orientation === 'horizontal' ? y : x;
83+
primary += this.getPrimaryStart(rect);
84+
secondary += this.getSecondaryStart(rect);
85+
86+
let flow = this.layout === 'stack' ? primary : secondary;
87+
let isPrimaryRTL = this.orientation === 'horizontal' && this.direction === 'rtl';
88+
let isSecondaryRTL = this.layout === 'grid' && this.orientation === 'vertical' && this.direction === 'rtl';
89+
let isFlowRTL = this.layout === 'stack' ? isPrimaryRTL : isSecondaryRTL;
2190

2291
let elements = this.ref.current.querySelectorAll('[data-key]');
2392
let elementMap = new Map<string, HTMLElement>();
@@ -35,11 +104,22 @@ export class ListDropTargetDelegate implements DropTargetDelegate {
35104
let item = items[mid];
36105
let element = elementMap.get(String(item.key));
37106
let rect = element.getBoundingClientRect();
107+
let update = (isGreater: boolean) => {
108+
if (isGreater) {
109+
low = mid + 1;
110+
} else {
111+
high = mid;
112+
}
113+
};
38114

39-
if (y < rect.top) {
40-
high = mid;
41-
} else if (y > rect.bottom) {
42-
low = mid + 1;
115+
if (primary < this.getPrimaryStart(rect)) {
116+
update(isPrimaryRTL);
117+
} else if (primary > this.getPrimaryEnd(rect)) {
118+
update(!isPrimaryRTL);
119+
} else if (secondary < this.getSecondaryStart(rect)) {
120+
update(isSecondaryRTL);
121+
} else if (secondary > this.getSecondaryEnd(rect)) {
122+
update(!isSecondaryRTL);
43123
} else {
44124
let target: DropTarget = {
45125
type: 'item',
@@ -49,19 +129,19 @@ export class ListDropTargetDelegate implements DropTargetDelegate {
49129

50130
if (isValidDropTarget(target)) {
51131
// Otherwise, if dropping on the item is accepted, try the before/after positions if within 5px
52-
// of the top or bottom of the item.
53-
if (y <= rect.top + 5 && isValidDropTarget({...target, dropPosition: 'before'})) {
54-
target.dropPosition = 'before';
55-
} else if (y >= rect.bottom - 5 && isValidDropTarget({...target, dropPosition: 'after'})) {
56-
target.dropPosition = 'after';
132+
// of the start or end of the item.
133+
if (flow <= this.getFlowStart(rect) + 5 && isValidDropTarget({...target, dropPosition: 'before'})) {
134+
target.dropPosition = isFlowRTL ? 'after' : 'before';
135+
} else if (flow >= this.getFlowEnd(rect) - 5 && isValidDropTarget({...target, dropPosition: 'after'})) {
136+
target.dropPosition = isFlowRTL ? 'before' : 'after';
57137
}
58138
} else {
59-
// If dropping on the item isn't accepted, try the target before or after depending on the y position.
60-
let midY = rect.top + rect.height / 2;
61-
if (y <= midY && isValidDropTarget({...target, dropPosition: 'before'})) {
62-
target.dropPosition = 'before';
63-
} else if (y >= midY && isValidDropTarget({...target, dropPosition: 'after'})) {
64-
target.dropPosition = 'after';
139+
// If dropping on the item isn't accepted, try the target before or after depending on the position.
140+
let mid = this.getFlowStart(rect) + this.getFlowSize(rect) / 2;
141+
if (flow <= mid && isValidDropTarget({...target, dropPosition: 'before'})) {
142+
target.dropPosition = isFlowRTL ? 'after' : 'before';
143+
} else if (flow >= mid && isValidDropTarget({...target, dropPosition: 'after'})) {
144+
target.dropPosition = isFlowRTL ? 'before' : 'after';
65145
}
66146
}
67147

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

76-
if (Math.abs(y - rect.top) < Math.abs(y - rect.bottom)) {
156+
if (primary < this.getPrimaryStart(rect) || Math.abs(flow - this.getFlowStart(rect)) < Math.abs(flow - this.getFlowEnd(rect))) {
77157
return {
78158
type: 'item',
79159
key: item.key,
80-
dropPosition: 'before'
160+
dropPosition: isFlowRTL ? 'after' : 'before'
81161
};
82162
}
83163

84164
return {
85165
type: 'item',
86166
key: item.key,
87-
dropPosition: 'after'
167+
dropPosition: isFlowRTL ? 'before' : 'after'
88168
};
89169
}
90170
}

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

Lines changed: 78 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {mergeProps, useId, useLayoutEffect} from '@react-aria/utils';
3838
import {setInteractionModality} from '@react-aria/interactions';
3939
import {useAutoScroll} from './useAutoScroll';
4040
import {useDrop} from './useDrop';
41+
import {useLocale} from '@react-aria/i18n';
4142

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

6162
const DROP_POSITIONS: DropPosition[] = ['before', 'on', 'after'];
63+
const DROP_POSITIONS_RTL: DropPosition[] = ['after', 'on', 'before'];
6264

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

320+
let {direction} = useLocale();
318321
useEffect(() => {
319-
let getNextTarget = (target: DropTarget, wrap = true): DropTarget => {
322+
let getNextTarget = (target: DropTarget, wrap = true, horizontal = false): DropTarget => {
320323
if (!target) {
321324
return {
322325
type: 'root'
323326
};
324327
}
325328

326329
let {keyboardDelegate} = localState.props;
327-
let nextKey = target.type === 'item'
328-
? keyboardDelegate.getKeyBelow(target.key)
329-
: keyboardDelegate.getFirstKey();
330-
let dropPosition: DropPosition = 'before';
330+
let nextKey: Key;
331+
if (target?.type === 'item') {
332+
nextKey = horizontal ? keyboardDelegate.getKeyRightOf(target.key) : keyboardDelegate.getKeyBelow(target.key);
333+
} else {
334+
nextKey = horizontal && direction === 'rtl' ? keyboardDelegate.getLastKey() : keyboardDelegate.getFirstKey();
335+
}
336+
let dropPositions = horizontal && direction === 'rtl' ? DROP_POSITIONS_RTL : DROP_POSITIONS;
337+
let dropPosition: DropPosition = dropPositions[0];
331338

332339
if (target.type === 'item') {
333-
let positionIndex = DROP_POSITIONS.indexOf(target.dropPosition);
334-
let nextDropPosition = DROP_POSITIONS[positionIndex + 1];
335-
if (positionIndex < DROP_POSITIONS.length - 1 && !(nextDropPosition === 'after' && nextKey != null)) {
336-
return {
337-
type: 'item',
338-
key: target.key,
339-
dropPosition: nextDropPosition
340-
};
341-
}
340+
// If the the keyboard delegate returned the next key in the collection,
341+
// first try the other positions in the current key. Otherwise (e.g. in a grid layout),
342+
// jump to the same drop position in the new key.
343+
let nextCollectionKey = horizontal && direction === 'rtl' ? localState.state.collection.getKeyBefore(target.key) : localState.state.collection.getKeyAfter(target.key);
344+
if (nextKey == null || nextKey === nextCollectionKey) {
345+
let positionIndex = dropPositions.indexOf(target.dropPosition);
346+
let nextDropPosition = dropPositions[positionIndex + 1];
347+
if (positionIndex < dropPositions.length - 1 && !(nextDropPosition === dropPositions[2] && nextKey != null)) {
348+
return {
349+
type: 'item',
350+
key: target.key,
351+
dropPosition: nextDropPosition
352+
};
353+
}
342354

343-
// If the last drop position was 'after', then 'before' on the next key is equivalent.
344-
// Switch to 'on' instead.
345-
if (target.dropPosition === 'after') {
346-
dropPosition = 'on';
355+
// If the last drop position was 'after', then 'before' on the next key is equivalent.
356+
// Switch to 'on' instead.
357+
if (target.dropPosition === dropPositions[2]) {
358+
dropPosition = 'on';
359+
}
360+
} else {
361+
dropPosition = target.dropPosition;
347362
}
348363
}
349364

@@ -364,28 +379,40 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
364379
};
365380
};
366381

367-
let getPreviousTarget = (target: DropTarget, wrap = true): DropTarget => {
382+
let getPreviousTarget = (target: DropTarget, wrap = true, horizontal = false): DropTarget => {
368383
let {keyboardDelegate} = localState.props;
369-
let nextKey = target?.type === 'item'
370-
? keyboardDelegate.getKeyAbove(target.key)
371-
: keyboardDelegate.getLastKey();
372-
let dropPosition: DropPosition = !target || target.type === 'root' ? 'after' : 'on';
384+
let nextKey: Key;
385+
if (target?.type === 'item') {
386+
nextKey = horizontal ? keyboardDelegate.getKeyLeftOf(target.key) : keyboardDelegate.getKeyAbove(target.key);
387+
} else {
388+
nextKey = horizontal && direction === 'rtl' ? keyboardDelegate.getFirstKey() : keyboardDelegate.getLastKey();
389+
}
390+
let dropPositions = horizontal && direction === 'rtl' ? DROP_POSITIONS_RTL : DROP_POSITIONS;
391+
let dropPosition: DropPosition = !target || target.type === 'root' ? dropPositions[2] : 'on';
373392

374393
if (target?.type === 'item') {
375-
let positionIndex = DROP_POSITIONS.indexOf(target.dropPosition);
376-
let nextDropPosition = DROP_POSITIONS[positionIndex - 1];
377-
if (positionIndex > 0 && nextDropPosition !== 'after') {
378-
return {
379-
type: 'item',
380-
key: target.key,
381-
dropPosition: nextDropPosition
382-
};
383-
}
394+
// If the the keyboard delegate returned the previous key in the collection,
395+
// first try the other positions in the current key. Otherwise (e.g. in a grid layout),
396+
// jump to the same drop position in the new key.
397+
let prevCollectionKey = horizontal && direction === 'rtl' ? localState.state.collection.getKeyAfter(target.key) : localState.state.collection.getKeyBefore(target.key);
398+
if (nextKey == null || nextKey === prevCollectionKey) {
399+
let positionIndex = dropPositions.indexOf(target.dropPosition);
400+
let nextDropPosition = dropPositions[positionIndex - 1];
401+
if (positionIndex > 0 && nextDropPosition !== dropPositions[2]) {
402+
return {
403+
type: 'item',
404+
key: target.key,
405+
dropPosition: nextDropPosition
406+
};
407+
}
384408

385-
// If the last drop position was 'before', then 'after' on the previous key is equivalent.
386-
// Switch to 'on' instead.
387-
if (target.dropPosition === 'before') {
388-
dropPosition = 'on';
409+
// If the last drop position was 'before', then 'after' on the previous key is equivalent.
410+
// Switch to 'on' instead.
411+
if (target.dropPosition === dropPositions[0]) {
412+
dropPosition = 'on';
413+
}
414+
} else {
415+
dropPosition = target.dropPosition;
389416
}
390417
}
391418

@@ -553,6 +580,20 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
553580
}
554581
break;
555582
}
583+
case 'ArrowLeft': {
584+
if (keyboardDelegate.getKeyLeftOf) {
585+
let target = nextValidTarget(localState.state.target, types, drag.allowedDropOperations, (target, wrap) => getPreviousTarget(target, wrap, true));
586+
localState.state.setTarget(target);
587+
}
588+
break;
589+
}
590+
case 'ArrowRight': {
591+
if (keyboardDelegate.getKeyRightOf) {
592+
let target = nextValidTarget(localState.state.target, types, drag.allowedDropOperations, (target, wrap) => getNextTarget(target, wrap, true));
593+
localState.state.setTarget(target);
594+
}
595+
break;
596+
}
556597
case 'Home': {
557598
if (keyboardDelegate.getFirstKey) {
558599
let target = nextValidTarget(null, types, drag.allowedDropOperations, getNextTarget);
@@ -654,7 +695,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
654695
}
655696
}
656697
});
657-
}, [localState, ref, onDrop]);
698+
}, [localState, ref, onDrop, direction]);
658699

659700
let id = useId();
660701
droppableCollectionMap.set(state, {id, ref});

0 commit comments

Comments
 (0)