Skip to content

Commit 94f0575

Browse files
authored
feat(dnd): useDrag: custom drag preview offset (#8445)
* useDrag: custom offset * allow passing getPreviewOffset into useDraggableCollectionState and useDragAndDrop * add stories for using with useDraggableCollectionState and useDragAndDrop * use renderDragPreview instead of getPreviewOffset
1 parent 8326f90 commit 94f0575

File tree

7 files changed

+333
-17
lines changed

7 files changed

+333
-17
lines changed

packages/@react-aria/dnd/src/DragPreview.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import {flushSync} from 'react-dom';
1515
import React, {ForwardedRef, JSX, useEffect, useImperativeHandle, useRef, useState} from 'react';
1616

1717
export interface DragPreviewProps {
18-
children: (items: DragItem[]) => JSX.Element | null
18+
/**
19+
* A render function which returns a preview element, or an object containing the element
20+
* and a custom offset. If an object is returned, the provided `x` and `y` values will be
21+
* used as the drag preview offset instead of the default calculation.
22+
*/
23+
children: (items: DragItem[]) => JSX.Element | {element: JSX.Element, x: number, y: number} | null
1924
}
2025

2126
export const DragPreview:
@@ -26,15 +31,29 @@ React.forwardRef(function DragPreview(props: DragPreviewProps, ref: ForwardedRef
2631
let domRef = useRef<HTMLDivElement | null>(null);
2732
let raf = useRef<ReturnType<typeof requestAnimationFrame> | undefined>(undefined);
2833

29-
useImperativeHandle(ref, () => (items: DragItem[], callback: (node: HTMLElement | null) => void) => {
34+
useImperativeHandle(ref, () => (items: DragItem[], callback: (node: HTMLElement | null, x?: number, y?: number) => void) => {
3035
// This will be called during the onDragStart event by useDrag. We need to render the
3136
// preview synchronously before this event returns so we can call event.dataTransfer.setDragImage.
37+
38+
let result = render(items);
39+
let element: JSX.Element | null;
40+
let offsetX: number | undefined;
41+
let offsetY: number | undefined;
42+
43+
if (result && typeof result === 'object' && 'element' in result) {
44+
element = result.element;
45+
offsetX = result.x;
46+
offsetY = result.y;
47+
} else {
48+
element = result as JSX.Element | null;
49+
}
50+
3251
flushSync(() => {
33-
setChildren(render(items));
52+
setChildren(element);
3453
});
3554

3655
// Yield back to useDrag to set the drag image.
37-
callback(domRef.current);
56+
callback(domRef.current, offsetX, offsetY);
3857

3958
// Remove the preview from the DOM after a frame so the browser has time to paint.
4059
raf.current = requestAnimationFrame(() => {

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

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export function useDrag(options: DragOptions): DragResult {
134134
// If there is a preview option, use it to render a custom preview image that will
135135
// appear under the pointer while dragging. If not, the element itself is dragged by the browser.
136136
if (typeof options.preview?.current === 'function') {
137-
options.preview.current(items, node => {
137+
options.preview.current(items, (node, userX, userY) => {
138138
if (!node) {
139139
return;
140140
}
@@ -143,18 +143,35 @@ export function useDrag(options: DragOptions): DragResult {
143143
// If the preview is much smaller, then just use the center point of the preview.
144144
let size = node.getBoundingClientRect();
145145
let rect = e.currentTarget.getBoundingClientRect();
146-
let x = e.clientX - rect.x;
147-
let y = e.clientY - rect.y;
148-
if (x > size.width || y > size.height) {
149-
x = size.width / 2;
150-
y = size.height / 2;
146+
let defaultX = e.clientX - rect.x;
147+
let defaultY = e.clientY - rect.y;
148+
if (defaultX > size.width || defaultY > size.height) {
149+
defaultX = size.width / 2;
150+
defaultY = size.height / 2;
151151
}
152152

153+
// Start with default offsets.
154+
let offsetX = defaultX;
155+
let offsetY = defaultY;
156+
157+
// If the preview renderer supplied explicit offsets, use those.
158+
if (typeof userX === 'number' && typeof userY === 'number') {
159+
offsetX = userX;
160+
offsetY = userY;
161+
}
162+
163+
// Clamp the offset so it stays within the preview bounds. Browsers
164+
// automatically clamp out-of-range values, but doing it ourselves
165+
// prevents the visible "snap" that can occur when the browser adjusts
166+
// them after the first drag update.
167+
offsetX = Math.max(0, Math.min(offsetX, size.width));
168+
offsetY = Math.max(0, Math.min(offsetY, size.height));
169+
153170
// Rounding height to an even number prevents blurry preview seen on some screens
154171
let height = 2 * Math.round(size.height / 2);
155172
node.style.height = `${height}px`;
156173

157-
e.dataTransfer.setDragImage(node, x, y);
174+
e.dataTransfer.setDragImage(node, offsetX, offsetY);
158175
});
159176
}
160177

packages/@react-aria/dnd/stories/dnd.stories.tsx

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,14 @@ function DraggableCollectionExample(props) {
385385
};
386386

387387
return (
388-
<DraggableCollection items={list.items} selectedKeys={list.selectedKeys} onSelectionChange={list.setSelectedKeys} onDragEnd={onDragEnd} onCut={onCut} isDisabled={props.isDisabled}>
388+
<DraggableCollection
389+
items={list.items}
390+
selectedKeys={list.selectedKeys}
391+
onSelectionChange={list.setSelectedKeys}
392+
onDragEnd={onDragEnd}
393+
onCut={onCut}
394+
isDisabled={props.isDisabled}
395+
{...props}>
389396
{item => (
390397
<Item textValue={item.text}>
391398
{item.type === 'folder' && <Folder size="S" />}
@@ -397,7 +404,7 @@ function DraggableCollectionExample(props) {
397404
}
398405

399406
function DraggableCollection(props) {
400-
let {isDisabled} = props;
407+
let {isDisabled, mode, offsetX, offsetY} = props;
401408
let ref = React.useRef<HTMLDivElement>(null);
402409
let state = useListState<ItemValue>(props);
403410
let gridState = useGridState({
@@ -472,7 +479,7 @@ function DraggableCollection(props) {
472479
let selectedKeys = dragState.draggingKeys;
473480
let draggedKey = [...selectedKeys][0];
474481
let item = state.collection.getItem(draggedKey);
475-
return (
482+
let element = (
476483
<div className={classNames(dndStyles, 'draggable', 'is-drag-preview', {'is-dragging-multiple': selectedKeys.size > 1})}>
477484
<div className={classNames(dndStyles, 'drag-handle')}>
478485
<ShowMenu size="XS" />
@@ -483,6 +490,12 @@ function DraggableCollection(props) {
483490
}
484491
</div>
485492
);
493+
494+
if (mode === 'custom') {
495+
return {element, x: offsetX, y: offsetY};
496+
}
497+
498+
return element;
486499
}}
487500
</DragPreview>
488501
</div>
@@ -575,3 +588,129 @@ export const DroppableEnabledDisabledControl: DnDStoryObj = {
575588
}
576589
}
577590
};
591+
592+
interface PreviewOffsetArgs {
593+
/** Strategy for positioning the preview. */
594+
mode: 'default' | 'custom',
595+
/** X offset in pixels (only used when mode = custom). */
596+
offsetX: number,
597+
/** Y offset in pixels (only used when mode = custom). */
598+
offsetY: number
599+
}
600+
601+
function DraggableWithPreview({mode, offsetX, offsetY}: PreviewOffsetArgs): JSX.Element {
602+
const preview = React.useRef(null);
603+
604+
const {dragProps, isDragging} = useDrag({
605+
getItems() {
606+
return [{
607+
'text/plain': 'preview offset demo'
608+
}];
609+
},
610+
preview,
611+
onDragStart: action('onDragStart'),
612+
onDragEnd: action('onDragEnd')
613+
});
614+
615+
const {clipboardProps} = useClipboard({
616+
getItems() {
617+
return [{
618+
'text/plain': 'preview offset demo'
619+
}];
620+
}
621+
});
622+
623+
const ref = React.useRef<HTMLDivElement>(null);
624+
const {buttonProps} = useButton({elementType: 'div'}, ref);
625+
626+
return (
627+
<>
628+
<div
629+
ref={ref}
630+
{...mergeProps(dragProps, buttonProps, clipboardProps)}
631+
className={classNames(dndStyles, 'draggable', {'is-dragging': isDragging})}
632+
style={{cursor: 'grab'}}>
633+
<ShowMenu size="XS" />
634+
<span>Drag me</span>
635+
</div>
636+
637+
{/* Custom drag preview */}
638+
<DragPreview ref={preview}>
639+
{() => {
640+
const elem = (
641+
<div className={classNames(dndStyles, 'draggable', 'is-drag-preview')}>
642+
<ShowMenu size="XS" />
643+
<span>Preview</span>
644+
</div>
645+
);
646+
647+
if (mode === 'custom') {
648+
return {element: elem, x: offsetX, y: offsetY};
649+
}
650+
651+
return elem;
652+
}}
653+
</DragPreview>
654+
</>
655+
);
656+
}
657+
658+
export const PreviewOffset: DnDStoryObj = {
659+
render: (args) => (
660+
<Flex direction="column" gap="size-200" alignItems="center">
661+
<DraggableWithPreview {...args} />
662+
<Droppable />
663+
</Flex>
664+
),
665+
name: 'Preview offset',
666+
argTypes: {
667+
mode: {
668+
control: 'select',
669+
options: ['default', 'custom'],
670+
defaultValue: 'default'
671+
},
672+
offsetX: {
673+
control: 'number',
674+
defaultValue: 20
675+
},
676+
offsetY: {
677+
control: 'number',
678+
defaultValue: 20
679+
}
680+
},
681+
args: {
682+
mode: 'default',
683+
offsetX: 20,
684+
offsetY: 20
685+
}
686+
};
687+
688+
export const CollectionPreviewOffset: DnDStoryObj = {
689+
render: (args) => (
690+
<Flex direction="column" gap="size-200" alignItems="center">
691+
<DraggableCollectionExample {...args} />
692+
<Droppable />
693+
</Flex>
694+
),
695+
name: 'Collection preview offset',
696+
argTypes: {
697+
mode: {
698+
control: 'select',
699+
options: ['default', 'custom'],
700+
defaultValue: 'default'
701+
},
702+
offsetX: {
703+
control: 'number',
704+
defaultValue: 20
705+
},
706+
offsetY: {
707+
control: 'number',
708+
defaultValue: 20
709+
}
710+
},
711+
args: {
712+
mode: 'default',
713+
offsetX: 20,
714+
offsetY: 20
715+
}
716+
};

packages/@react-aria/dnd/test/dnd.test.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,59 @@ describe('useDrag and useDrop', function () {
13501350
expect(dataTransfer._dragImage.x).toBe(10);
13511351
expect(dataTransfer._dragImage.y).toBe(10);
13521352
});
1353+
1354+
it('should use the offset returned from renderPreview', () => {
1355+
let renderPreview = jest.fn().mockImplementation(() => ({element: <div>Drag preview</div>, x: 12, y: 15}));
1356+
let tree = render(<Draggable renderPreview={renderPreview} />);
1357+
1358+
let draggable = tree.getByText('Drag me');
1359+
1360+
// Ensure consistent element sizes between draggable source and preview.
1361+
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
1362+
return {
1363+
left: 0,
1364+
top: 0,
1365+
x: 0,
1366+
y: 0,
1367+
width: this.style.position === 'absolute' ? 20 : 100,
1368+
height: this.style.position === 'absolute' ? 20 : 50
1369+
};
1370+
});
1371+
1372+
let dataTransfer = new DataTransfer();
1373+
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 10, clientY: 10}));
1374+
1375+
// renderPreview should have been called and its offset returned used without modification.
1376+
expect(renderPreview).toHaveBeenCalledTimes(1);
1377+
expect(dataTransfer._dragImage.x).toBe(12);
1378+
expect(dataTransfer._dragImage.y).toBe(15);
1379+
});
1380+
1381+
it('should clamp the offset returned from renderPreview to the preview bounds', () => {
1382+
let renderPreview = jest.fn().mockImplementation(() => ({element: <div>Drag preview</div>, x: 50, y: -10}));
1383+
// Return values outside of the preview bounds to verify clamping logic.
1384+
let tree = render(<Draggable renderPreview={renderPreview} />);
1385+
1386+
let draggable = tree.getByText('Drag me');
1387+
1388+
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
1389+
return {
1390+
left: 0,
1391+
top: 0,
1392+
x: 0,
1393+
y: 0,
1394+
width: this.style.position === 'absolute' ? 20 : 100,
1395+
height: this.style.position === 'absolute' ? 20 : 50
1396+
};
1397+
});
1398+
1399+
let dataTransfer = new DataTransfer();
1400+
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));
1401+
1402+
// Offsets should be clamped to 0 <= offset <= width/height (20 in this mock).
1403+
expect(dataTransfer._dragImage.x).toBe(20);
1404+
expect(dataTransfer._dragImage.y).toBe(0);
1405+
});
13531406
});
13541407
});
13551408

packages/@react-types/shared/src/dnd.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ export interface DraggableCollectionEndEvent extends DragEndEvent {
275275
isInternal: boolean
276276
}
277277

278-
export type DragPreviewRenderer = (items: DragItem[], callback: (node: HTMLElement | null) => void) => void;
278+
export type DragPreviewRenderer = (items: DragItem[], callback: (node: HTMLElement | null, x?: number, y?: number) => void) => void;
279279

280280
export interface DraggableCollectionProps {
281281
/** Handler that is called when a drag operation is started. */

packages/react-aria-components/src/useDragAndDrop.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ interface DragHooks {
5050
useDraggableCollection?: (props: DraggableCollectionOptions, state: DraggableCollectionState, ref: RefObject<HTMLElement | null>) => void,
5151
useDraggableItem?: (props: DraggableItemProps, state: DraggableCollectionState) => DraggableItemResult,
5252
DragPreview?: typeof DragPreview,
53-
renderDragPreview?: (items: DragItem[]) => JSX.Element,
53+
renderDragPreview?: (items: DragItem[]) => JSX.Element | {element: JSX.Element, x: number, y: number},
5454
isVirtualDragging?: () => boolean
5555
}
5656

@@ -81,7 +81,7 @@ export interface DragAndDropOptions extends Omit<DraggableCollectionProps, 'prev
8181
* A function that renders a drag preview, which is shown under the user's cursor while dragging.
8282
* By default, a copy of the dragged element is rendered.
8383
*/
84-
renderDragPreview?: (items: DragItem[]) => JSX.Element,
84+
renderDragPreview?: (items: DragItem[]) => JSX.Element | {element: JSX.Element, x: number, y: number},
8585
/**
8686
* A function that renders a drop indicator element between two items in a collection.
8787
* This should render a `<DropIndicator>` element. If this function is not provided, a

0 commit comments

Comments
 (0)