Skip to content

Commit 8a57be0

Browse files
authored
Add Drag and Drop (#6258)
* add Draggable and Droppable components * wild style * add droppable context and styles * start moving draggable nodes around * nice animations * fix multi-list interactions * put blankDiv in proper place for multi-list * work on onDrop * single list onDrop working * multilist bugfixes * add snapshot * update snapshot
1 parent 155eb0c commit 8a57be0

File tree

11 files changed

+602
-4
lines changed

11 files changed

+602
-4
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as React from 'react';
2+
3+
interface DraggableItemPosition {
4+
/** Parent droppableId */
5+
droppableId: string;
6+
/** Index of item in parent Droppable */
7+
index: number;
8+
}
9+
10+
export const DragDropContext = React.createContext({
11+
onDrag: (_source: DraggableItemPosition) => true as boolean,
12+
onDrop: (_source: DraggableItemPosition, _dest?: DraggableItemPosition) => false as boolean
13+
});
14+
15+
interface DragDropProps {
16+
/** Potentially Droppable and Draggable children */
17+
children?: React.ReactNode;
18+
/** Callback for drag event. Return true to allow drag, false to disallow. */
19+
onDrag?: (source: DraggableItemPosition) => boolean;
20+
/** Callback for drop event. Return true to allow drop, false to disallow. */
21+
onDrop?: (source: DraggableItemPosition, dest?: DraggableItemPosition) => boolean;
22+
}
23+
24+
export const DragDrop: React.FunctionComponent<DragDropProps> = ({
25+
children,
26+
onDrag = () => true,
27+
onDrop = () => false
28+
}: DragDropProps) => <DragDropContext.Provider value={{ onDrag, onDrop }}>{children}</DragDropContext.Provider>;
29+
DragDrop.displayName = 'DragDrop';
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import * as React from 'react';
2+
import { createPortal } from 'react-dom';
3+
import { DroppableContext } from './DroppableContext';
4+
import { DragDropContext } from './DragDrop';
5+
6+
export interface DraggableProps extends React.HTMLProps<HTMLDivElement> {
7+
/** Content rendered inside DragDrop */
8+
children?: React.ReactNode;
9+
/** Class to add to outer div */
10+
className?: string;
11+
}
12+
13+
// Browsers really like being different from each other.
14+
function getDefaultBackground() {
15+
const div = document.createElement('div');
16+
document.head.appendChild(div);
17+
const bg = window.getComputedStyle(div).backgroundColor;
18+
document.head.removeChild(div);
19+
return bg;
20+
}
21+
22+
function getInheritedBackgroundColor(el: HTMLElement): string {
23+
const defaultStyle = getDefaultBackground();
24+
const backgroundColor = window.getComputedStyle(el).backgroundColor;
25+
26+
if (backgroundColor !== defaultStyle) {
27+
return backgroundColor;
28+
} else if (!el.parentElement) {
29+
return defaultStyle;
30+
}
31+
32+
return getInheritedBackgroundColor(el.parentElement);
33+
}
34+
35+
function removeBlankDiv(node: HTMLDivElement) {
36+
if (node.getAttribute('blankDiv') === 'true') {
37+
// eslint-disable-next-line @typescript-eslint/prefer-for-of
38+
for (let i = 0; i < node.children.length; i++) {
39+
const child = node.children[i];
40+
if (child.getAttribute('blankDiv') === 'true') {
41+
node.removeChild(child);
42+
node.setAttribute('blankDiv', 'false');
43+
break;
44+
}
45+
}
46+
}
47+
}
48+
49+
interface DroppableItem {
50+
node: HTMLDivElement;
51+
rect: DOMRect;
52+
isDraggingHost: boolean;
53+
draggableNodes: HTMLDivElement[];
54+
draggableNodesRects: DOMRect[];
55+
}
56+
57+
// Reset per-element state
58+
function resetDroppableItem(droppableItem: DroppableItem) {
59+
removeBlankDiv(droppableItem.node);
60+
droppableItem.node.style.boxShadow = '';
61+
droppableItem.draggableNodes.forEach((n, i) => {
62+
n.style.transform = '';
63+
n.style.transition = '';
64+
droppableItem.draggableNodesRects[i] = n.getBoundingClientRect();
65+
});
66+
}
67+
68+
function overlaps(ev: MouseEvent, rect: DOMRect) {
69+
return (
70+
ev.clientX > rect.x && ev.clientX < rect.x + rect.width && ev.clientY > rect.y && ev.clientY < rect.y + rect.height
71+
);
72+
}
73+
74+
export const Draggable: React.FunctionComponent<DraggableProps> = ({
75+
className,
76+
children,
77+
style: styleProp,
78+
...props
79+
}: DraggableProps) => {
80+
/* eslint-disable prefer-const */
81+
let [style, setStyle] = React.useState(styleProp);
82+
/* eslint-enable prefer-const */
83+
const [isDragging, setIsDragging] = React.useState(false);
84+
const { zone, droppableId } = React.useContext(DroppableContext);
85+
const { onDrag, onDrop } = React.useContext(DragDropContext);
86+
const ref = React.createRef<HTMLDivElement>();
87+
// Some state is better just to leave as vars passed around between various callbacks
88+
// You can only drag around one item at a time anyways...
89+
let startX = 0;
90+
let startY = 0;
91+
let index: number = null; // Index of this draggable
92+
let hoveringDroppable: HTMLDivElement;
93+
let hoveringIndex: number = null;
94+
let mouseMoveListener: EventListener;
95+
let mouseUpListener: EventListener;
96+
// Makes it so dragging the _bottom_ of the item over the halfway of another moves it
97+
let startYOffset = 0;
98+
99+
// After item returning to where it started animation completes
100+
const onTransitionEnd = (_ev: React.TransitionEvent<HTMLDivElement>) => {
101+
if (isDragging) {
102+
setIsDragging(false);
103+
setStyle(styleProp);
104+
}
105+
};
106+
107+
const onMouseUpWhileDragging = (droppableItems: DroppableItem[]) => {
108+
droppableItems.forEach(resetDroppableItem);
109+
document.removeEventListener('mousemove', mouseMoveListener);
110+
document.removeEventListener('mouseup', mouseUpListener);
111+
document.removeEventListener('contextmenu', mouseUpListener);
112+
const hoveringDroppableId = hoveringDroppable ? hoveringDroppable.getAttribute('data-pf-droppableid') : null;
113+
const source = {
114+
droppableId,
115+
index
116+
};
117+
const dest =
118+
hoveringDroppableId !== null && hoveringIndex !== null
119+
? {
120+
droppableId: hoveringDroppableId,
121+
index: hoveringIndex
122+
}
123+
: undefined;
124+
const consumerReordered = onDrop(source, dest);
125+
if (consumerReordered && droppableId === hoveringDroppableId) {
126+
setIsDragging(false);
127+
setStyle(styleProp);
128+
} else if (!consumerReordered) {
129+
// Animate item returning to where it started
130+
setStyle({
131+
...style,
132+
transition: 'transform 0.5s cubic-bezier(0.2, 1, 0.1, 1) 0s',
133+
transform: '',
134+
background: styleProp.background,
135+
boxShadow: styleProp.boxShadow
136+
});
137+
}
138+
};
139+
140+
// This is where the magic happens
141+
const onMouseMoveWhileDragging = (ev: MouseEvent, droppableItems: DroppableItem[], blankDivRect: DOMRect) => {
142+
// Compute each time what droppable node we are hovering over
143+
hoveringDroppable = null;
144+
droppableItems.forEach(droppableItem => {
145+
const { node, rect, isDraggingHost, draggableNodes, draggableNodesRects } = droppableItem;
146+
if (overlaps(ev, rect)) {
147+
// Add valid dropzone style
148+
node.style.boxShadow = '0px 0px 0px 1px blue, 0px 2px 5px rgba(0, 0, 0, 0.2)';
149+
hoveringDroppable = node;
150+
// Check if we need to add a blank div row
151+
if (node.getAttribute('blankDiv') !== 'true' && !isDraggingHost) {
152+
const blankDiv = document.createElement('div');
153+
blankDiv.setAttribute('blankDiv', 'true'); // Makes removing easier
154+
let blankDivPos = -1;
155+
for (let i = 0; i < draggableNodes.length; i++) {
156+
const childRect = draggableNodesRects[i];
157+
const isLast = i === draggableNodes.length - 1;
158+
const startOverlaps = childRect.y >= startY - startYOffset;
159+
if ((startOverlaps || isLast) && blankDivPos === -1) {
160+
if (isLast && !startOverlaps) {
161+
draggableNodes[i].after(blankDiv);
162+
} else {
163+
draggableNodes[i].before(blankDiv);
164+
}
165+
blankDiv.style.height = `${blankDivRect.height}px`;
166+
blankDiv.style.width = `${blankDivRect.width}px`;
167+
node.setAttribute('blankDiv', 'true'); // Makes removing easier
168+
blankDivPos = i;
169+
}
170+
if (blankDivPos !== -1) {
171+
childRect.y += blankDivRect.height;
172+
}
173+
}
174+
// Insert so drag + drop behavior matches single-list case
175+
draggableNodes.splice(blankDivPos, 0, blankDiv);
176+
draggableNodesRects.splice(blankDivPos, 0, blankDivRect);
177+
// Extend hitbox of droppable zone
178+
rect.height += blankDivRect.height;
179+
}
180+
} else {
181+
resetDroppableItem(droppableItem);
182+
node.style.boxShadow = '0px 0px 0px 1px red, 0px 2px 5px rgba(0, 0, 0, 0.2)';
183+
}
184+
});
185+
186+
// Move hovering draggable and style it based on cursor position
187+
setStyle({
188+
...style,
189+
boxShadow: `0px 0px 0px 1px ${hoveringDroppable ? 'blue' : 'red'}, 0px 2px 5px rgba(0, 0, 0, 0.2)`,
190+
transform: `translate(${ev.pageX - startX}px, ${ev.pageY - startY}px)`,
191+
cursor: !hoveringDroppable && 'not-allowed'
192+
});
193+
194+
// Iterate through sibling draggable nodes to reposition them and store correct hoveringIndex for onDrop
195+
hoveringIndex = null;
196+
if (hoveringDroppable) {
197+
const { draggableNodes, draggableNodesRects } = droppableItems.find(item => item.node === hoveringDroppable);
198+
let lastTranslate = 0;
199+
draggableNodes.forEach((n, i) => {
200+
n.style.transition = 'transform 0.5s cubic-bezier(0.2, 1, 0.1, 1) 0s';
201+
const rect = draggableNodesRects[i];
202+
const halfway = rect.y + rect.height / 2;
203+
let translateY = 0;
204+
// Use offset for more interactive translations
205+
if (startY < halfway && ev.pageY + (blankDivRect.height - startYOffset) > halfway) {
206+
translateY -= blankDivRect.height;
207+
} else if (startY >= halfway && ev.pageY - startYOffset <= halfway) {
208+
translateY += blankDivRect.height;
209+
}
210+
// Clever way to find item currently hovering over
211+
if ((translateY <= lastTranslate && translateY < 0) || (translateY > lastTranslate && translateY > 0)) {
212+
hoveringIndex = i;
213+
}
214+
n.style.transform = `translate(0, ${translateY}px`;
215+
lastTranslate = translateY;
216+
});
217+
}
218+
};
219+
220+
const onDragStart = (ev: React.DragEvent<HTMLDivElement>) => {
221+
// Default HTML drag and drop doesn't allow us to change what the thing
222+
// being dragged looks like. Because of this we'll use prevent the default
223+
// and use `mouseMove` and `mouseUp` instead
224+
ev.preventDefault();
225+
if (isDragging) {
226+
// still in animation
227+
return;
228+
}
229+
230+
// Cache droppable and draggable nodes and their bounding rects
231+
const rect = ref.current.getBoundingClientRect();
232+
const droppableNodes = Array.from(document.querySelectorAll(`[data-pf-droppable="${zone}"]`)) as HTMLDivElement[];
233+
const droppableItems = droppableNodes.reduce((acc, cur) => {
234+
const draggableNodes = Array.from(cur.querySelectorAll(`[data-pf-draggable-zone="${zone}"]`)) as HTMLDivElement[];
235+
const isDraggingHost = cur.contains(ref.current);
236+
if (isDraggingHost) {
237+
index = draggableNodes.indexOf(ref.current);
238+
}
239+
const droppableItem = {
240+
node: cur,
241+
rect: cur.getBoundingClientRect(),
242+
isDraggingHost,
243+
draggableNodes,
244+
draggableNodesRects: draggableNodes.map(node => node.getBoundingClientRect())
245+
};
246+
acc.push(droppableItem);
247+
return acc;
248+
}, []);
249+
250+
if (!onDrag({ droppableId, index })) {
251+
// Consumer disallowed drag
252+
return;
253+
}
254+
255+
// Set initial style so future style mods take effect
256+
style = {
257+
...style,
258+
position: 'fixed',
259+
top: rect.y,
260+
left: rect.x,
261+
width: rect.width,
262+
height: rect.height,
263+
background: getInheritedBackgroundColor(ref.current),
264+
boxShadow: '0px 0px 0px 1px blue, 0px 2px 5px rgba(0, 0, 0, 0.2)',
265+
zIndex: 5000
266+
};
267+
setStyle(style);
268+
// Store event details
269+
startX = ev.pageX;
270+
startY = ev.pageY;
271+
startYOffset = startY - rect.y;
272+
setIsDragging(true);
273+
mouseMoveListener = ev => onMouseMoveWhileDragging(ev as MouseEvent, droppableItems, rect);
274+
mouseUpListener = () => onMouseUpWhileDragging(droppableItems);
275+
document.addEventListener('mousemove', mouseMoveListener);
276+
document.addEventListener('mouseup', mouseUpListener);
277+
// Comment out this line to debug while dragging by right clicking
278+
document.addEventListener('contextmenu', mouseUpListener);
279+
};
280+
281+
const div = (
282+
<div
283+
data-pf-draggable-zone={isDragging ? null : zone}
284+
draggable
285+
role="button"
286+
className={className}
287+
onDragStart={onDragStart}
288+
onTransitionEnd={onTransitionEnd}
289+
style={style}
290+
ref={ref}
291+
{...props}
292+
>
293+
{children}
294+
</div>
295+
);
296+
297+
return (
298+
<React.Fragment>
299+
{/* Leave behind blank spot per-design */}
300+
{isDragging && (
301+
<div draggable role="button" {...props} style={{ ...styleProp, visibility: 'hidden' }}>
302+
{children}
303+
</div>
304+
)}
305+
{/* Move dragging part into portal to appear above top and side nav */}
306+
{isDragging ? createPortal(div, document.body) : div}
307+
</React.Fragment>
308+
);
309+
};
310+
Draggable.displayName = 'Draggable';
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as React from 'react';
2+
import { DroppableContext } from './DroppableContext';
3+
4+
interface DroppableProps extends React.HTMLProps<HTMLDivElement> {
5+
/** Content rendered inside DragDrop */
6+
children?: React.ReactNode;
7+
/** Class to add to outer div */
8+
className?: string;
9+
/** Name of zone that items can be dragged between. Should specify if there is more than one Droppable on the page. */
10+
zone?: string;
11+
/** Id to be passed back on drop events */
12+
droppableId?: string;
13+
}
14+
15+
export const Droppable: React.FunctionComponent<DroppableProps> = ({
16+
className,
17+
children,
18+
zone = 'defaultZone',
19+
droppableId = 'defaultId',
20+
...props
21+
}: DroppableProps) => (
22+
<DroppableContext.Provider value={{ zone, droppableId }}>
23+
<div data-pf-droppable={zone} data-pf-droppableid={droppableId} className={className} {...props}>
24+
{children}
25+
</div>
26+
</DroppableContext.Provider>
27+
);
28+
Droppable.displayName = 'Droppable';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as React from 'react';
2+
3+
export const DroppableContext = React.createContext({
4+
zone: 'defaultDroppableZone',
5+
droppableId: 'defaultDroppableId'
6+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
import { shallow } from 'enzyme';
3+
import { DragDrop, Draggable, Droppable } from '../';
4+
5+
test('renders some divs', () => {
6+
const view = shallow(
7+
<DragDrop>
8+
<Droppable droppableId="dropzone">
9+
<Draggable draggableId="draggable1">
10+
item 1
11+
</Draggable>
12+
<Draggable draggableId="draggable2">
13+
item 2
14+
</Draggable>
15+
</Droppable>
16+
</DragDrop>
17+
);
18+
expect(view).toMatchSnapshot();
19+
});

0 commit comments

Comments
 (0)