From scratch Drag and Drop - Pitfalls with Scroll Anchoring and touch gestures
Tags: javascript, css
Published: 2022-04-26 05:32:00

I've been writing some from-scratch drag and drop code for my web app. I decided not to go with the built-in drag/drop functionality of HTML because I wanted to fully control the drop preview, which does not seem to be possible with the built-in APIs. I went with the `pointer` events since those work with touch and mouse interactions, and because it has `setPointerCapture`.

## High-level approach with `pointer` events

1. Make a drag handle element that shows up on mouse hover.
2. On `pointerdown` on the drag handle, call `document.body.setPointerCapture(e.pointerId)` to send all mouse events directly to document body (since the original element might get detached as it gets moved around).
3. Set up `pointermove` and `pointerup` handlers on `document.body` to handle drag operations.
4. Use a bunch of layout math to find where in the list the item would be dropped, and swap around elements to reflect that.
5. When the mouse is close to the top or bottom of the scroll pane, automatically scroll the pane up or down (proportional to how far the mouse is inside the autoscroll zone).
6. On `pointerup`, commit the drag operation and detach all the temporary event handlers from `document.body`.

## Mysterious scroll changes

I got it working but was flummoxed when I noticed the scroll snapping around crazily when auto-scrolling up. After a bunch of searching and dead ends, I found that swapping elements with `insertBefore()` (which is how my framework was implementing list order updates) could sometimes change the scroll position if the swapped element was at the top of the scroll area.

I got a [minimal repro](https://jsfiddle.net/RandomEngy/uxsrvbqa/5/) going and noticed that Safari didn't have the issue. After a lot of fruitless searching I [reported it as a browser bug](https://bugs.chromium.org/p/chromium/issues/detail?id=1310563) but it turns out this is just an edge case with [Scroll Anchoring](https://www.w3.org/TR/css-scroll-anchoring-1/). 😳

The problem that Scroll Anchoring is trying to solve is elements above the viewport out of view popping in or changing size and causing the view to jump around. They choose an anchor element (the first visible element) and if that node moves, they figure that probably means it was due to some shenanigans happening above it, then they update the scroll value to track that anchor node's movement. Except in my case it had chosen one of the elements that I was swapping around while dragging as an anchor element and was messing things up.

Fortunately, there's an easy fix: set `overflow-anchor: none` on the list element. I didn't need the behavior because nothing else in the list was changing size. An interesting observation here is that `overflow-anchor: none` could be useful even when the auto-scroll isn't triggering: turning off scroll anchoring will bypass the anchor selection process and make DOM updates inside the scroll area a little bit faster.

## Suppressing touch gestures

The last hurdle was fixing up the drag on mobile: I needed to temporarily apply `touch-action: none` to the list during the drag so my pointer operation wouldn't get canceled by an automatically detected touch gesture.