November 03, 2024

Drag and drop (it like it's hot)

Skeuomorphism - the idea that digital elements resemble their real-world counterparts - arguably reached its peak with Apple’s iOS 6. However the idea is both much older than iOS and still very much alive today.

I bet that you pushed more digital that real-world buttons today, and that your digital trash-bin has hundreds-times more stuff in it (no I have not checked:).

Of course programmers and designers continue to leverage our real-world skills, in order to make apps intuitive. Both quick and safe to navigate and to use.

However putting a familiar looking icon onto buttons is just the first step. Interactions such as momentum scrolling and pinch zoom, match our real-world expectations and thus became universal, across all computing devices.

There is one very old and still widely used interaction type. Naturally we are talking about drag&drop. In this blog we want to discuss some Typescript/React code for handling drag&drop.

Handle Drag and drop in React with ease.

There are two major reasons use cases for drag&drop in webapps.

  1. To re-arrange objects within your app. For example to allow the user to personalise a dashboard by moving panels around. Or to re-schedule events on calendar
  2. To handle file uploads, eg. a user drag&drops files from Finder/Explorer/Dolphin, etc.

Drag & Drop HTML Elements.

First, we want to show you how we dealt with the first scenario in our React app.

We have abstracted the “rearrange” items in list into a simple hook. The code will not actually move the HTML elements, instead it will highlight the target item, to indicate the dragged element will be placed logically before the highlighted item.

Testing various strategies, we found that physically re-arranging items on drag, will shuffle items in an unpredictable way. Especially within grid layouts with non-uniform element sizes, and it also makes it difficult for the user to cancel the operation.

One big benefit from this decision is that React rendering is not triggered while the dragging operation is on-going, css classes are toggled with raw DOM access on html elements.

The hook accepts a single finished-dragging on-drop callback function. This is when we expect you to update your data and re-render elements in the new order.

Now, we understand that this decision might be a disadvantage in your app. Since the code is quite short and self-contained, we hope you might still find it useful.

One of our requirements for the code was to be data agnostic and html agnostic. In a nutshell, the code expects only 2 things.

  1. The html element has a drag_index data attribute
  2. The html element has a drag_kind data attribute

The first attribute is reported to the drop callback. In our code we put the index of the data item, but this is purely up to you. The seconds attribute is only needed if you have multiple drag&drop areas and you want to prevent a mixup between them.

The code does not expect the elements to be children of the same parent. It also does not expect the elements to be leaf (without children). In other words there are no constraints on what the content of the dragged element is or what their relationship is. Again this was a design choice. We did not want the drag&drop functionality to affect the rendering code, because re-arranging was not a top priority of the code. Because, we did not want the app to pay a price in complexity and performance for a feature used by a small subset of users.

With the disclaimers and concepts out of the way, lets look at the code.

There are 4 major pieces to drag&drop.

Drag Start

First is the onDragStart function. The only interesting thing in this function is that we copy the drag kind and index into the drag event’s dataTransfer object.

const onDragStart = (e: React.DragEvent) => {
	const ds = (e.target as HTMLElement).dataset;
	e.dataTransfer.setData("drag_index_" + ds["drag_index"], "");
	e.dataTransfer.setData("drag_kind_" + ds["kind"], "");
}

Drag over

Then there is the triplet of events for potential drop target. We need to do 2 things, first to check whether the drop target element can accept the dragged element. The second thing to take care of is to highlight the drop element as accepting the drag.

First notice that in our code elements that can be dragged are also the places where we can drop. This is not true in general, we just find that this simplifies the code. And also the concept for the end-user.

Because the events bubble, the event target - the actual html element that is dragged over - might not necessarily be the element we designed as drop (and also drag) target, but a child. Thus we can have drag over and leave events firing among children of the designated drop target and we don’t care about them! The solution we came up with is to count the enter/leave events. Once we hit 0 we know none of the children are targeted and thus we can stop highlighting the drop element.

The workhorse is the helper function onDragOverInternal. It will look for the right element (either the event target or a parent), and it will check the dragged element is the right kind and we are not dragging over itself.

You might feel the onDragOver function is not really doing anything, but we actually have to set the event’s effect. Otherwise there will be no drop.

const onDragOverInternal = (e: React.DragEvent, canCopy:(dragIndex:number, dropIndex:number, kind:string, target: HTMLElement)=>void) => {

	let target = e.target as HTMLElement;
	while (target && !target.draggable) // or we could check the dataset['kind']
		target = target.parentElement as HTMLElement;
	
	if (!target)
		return;

	e.stopPropagation();

	let z = e.dataTransfer.types.find(x => x.startsWith("drag_index"));
	if (!z)
		return;
	
	const dragIndex = +z.substring("drag_index_".length);
	const dragKind = (e.dataTransfer.types.find(x => x.startsWith("drag_kind"))?.substring("drag_kind_".length)) || "";

	const dropIndex = +(target.dataset["drag_index"] || 0);
	const dropKind = target.dataset["kind"];

	if (dragIndex !== dropIndex && dropKind == dragKind) {
		e.dataTransfer.dropEffect = "copy";
		e.preventDefault();
		canCopy(dragIndex, dropIndex, dragKind, target);
	}
}
export const onDragOver = (e: React.DragEvent) => {
	onDragOverInternal(e, (drag, drop, kind, target) => { });
}
export const onDragEnter = (e: React.DragEvent) => {
	onDragOverInternal(e, (drag, drop, kind, target) => {
		target.style.backgroundColor = "#ccffcc";
		target.dataset["drop_zone_enter_counter"] = "" + (+(target.dataset["drop_zone_enter_counter"] || "0") + 1);
		target.classList.add("dropZoneActive");
	})
}
export const onDragLeave = (e: React.DragEvent) => {
	onDragOverInternal(e, (drag, drop, kind, target) => {
		const dragEnter = (+(target.dataset["drop_zone_enter_counter"] || "0") - 1);
		target.dataset["drop_zone_enter_counter"] = "" + dragEnter;
		if (dragEnter === 0) {
			target.classList.remove("dropZoneActive");
			target.style.backgroundColor = "";
		}
	});
}

(Drag) drop

Finally the onDrop event will again use the onDragOverInternal and just pass the drag and drop element info to the provided callback. In the finished-dragging callback we simply move the element to the right position, and we let React to redraw the component.

export const useDragProps = (draggable: boolean = true,
	executeDrop: (dragIndex: number, dropIndex: number, kind: string, target: HTMLElement) => void,
	monitor?: any[]
) => {

	const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
		onDragOverInternal(e, async (dragIndex, dropIndex, kind, elm) => {
			if (kind) {
				onDragLeave(e);
				executeDrop(dragIndex, dropIndex, kind, elm);
			}
		})
	}, monitor ? monitor : [executeDrop]);

	const dragHandlers = useMemo(() => ({
		onDragEnter, onDragLeave, onDragOver, onDragStart, onDrop, draggable: draggable
	}), [draggable, onDrop]);
	
	return dragHandlers;
}

Recap

That was quite a lot, so let's recap what we made.

  1. onDragStart, copies the dataset attributes to the drag events dataTransfer object.
  2. onDragOver checks the potential drop target can accept the dataTransfer (right kind and different index) and sets the effect.
  3. onDragEnter and onDragLeave keep the potential drop target highlighted, by counting how many times the events fired.
  4. onDrop will call the caller provided callback with the operation results.

Usage

And here it is, drag&drop simplified :)


export const DragTable = () => {

	const [items, setItems] = useState(['A', 'B', 'C', 'D', 'E', 'F', 'G']);

	const dragEvents = useDragProps(true, (dragIndex, dropIndex) => {
		setItems(oldItems => {
			const newItems = [...oldItems];
			const field = newItems.splice(dragIndex, 1)[0];
			newItems.splice(dropIndex, 0, field);
			return newItems;
		})
	});

	return <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap:"2px" }}>
		{items.map((x, index) => {
			return <div key={x} {...dragEvents} data-kind="alphabet" data-drag_index={index} style={{border:"1px solid black", display:"grid", placeItems:"center", fontSize:"40px"}}>{x}</div>
		})}
	</div>
}

Wrap

We hope this was useful and if you can't use the code verbatim, we atleast documented a couple of tricks and gotchas.
Next time we will take a look at file drag&drop.

Don't forget to have fun.

Happy hacking!

Continue reading

Ultimate HTML input elements

Processing bank transfer in Salesforce.com

Sign in with Facebook

©2022-2024 Inuko s.r.o
Privacy PolicyCloud
ENSKCZ
ICO 54507006
VAT SK2121695400
Vajanskeho 5
Senec 90301
Slovakia EU

contact@inuko.net