Using the HTML5 Drag and Drop API to implement column reordering
Lately I’ve been building a web-based table component at work and in this post I want to talk about implementing drag and drop column reordering. The HTML5 Drag and Drop API has many quirks that make this harder than expected.
At this point it almost feels mandatory to justify why we are building our own table component in the age of ready made React components and I’d name reasons such as:
- It is really not that hard (may change my mind if we ever need true server-side paging)
- When all the requirements stack up it actually becomes hard to find a ready-made component that supports your use case
- We are building in Dioxus and want to avoid JS (this is a whole new set of tradeoffs I will not touch on here)
Despite us building in Dioxus almost all things in this post should equally work in React and other JS frameworks.
A dedicated drag handle
Because the table header will contain interactive elements, I want a
dedicated drag handle that has to be clicked and held down to drag the
header. But I can’t just make the handle draggable
because
then the ghost image would be only of the handle and not the full
header.
Luckily this
Stack Overflow post has the answer. Set draggable
on
the header when the handle gets the mousedown
event. You
also need to remove it when the handle receives mouseup
or
when the header receives dragend
so that the header doesn’t
remain draggable on its own.
Drop zones
Simply using the column header elements as drop zones does not work
if they have children such as the drag handle because the children will
block the dragover
and drop
events we are
interested in. You could set pointer-events: none
on the
children but this will break interactive elements.
We also have to realize that there are two types of reordering operations we could implement. One column can be dragged onto another column to swap them, or one column can be dragged onto the border between two columns to move it to that location. If a user wants to achieve a desired column order, thinking in terms of moving columns is much easier than thinking about the order in which columns have to be swapped to arrive at the desired state. Therefore we will only implement moving columns and not swapping columns since this is generally what users expect.
Putting things together we know that we need dedicated drop zone elements that are positioned in between columns. We don’t want to waste space in between columns so we will use invisible drop zone elements that are overlayed while the user is in the process of dragging a column.
The fact that there are N columns and N+1 drop zones makes it annoying to render both in the same loop. My first attempt was to work around this by splitting each column header in half to form half of a drop zone.
But there is an issue with this approach. Even if the two elements
that form one logical drop zone are touching with no gap in between,
when moving the mouse between them your code will receive the
dragleave
event before the dragenter
and
dragover
events. This means that there will be a short
period where your code thinks that the mouse left the element which will
result in a flicker of the dragover indicator that we will add
later.
CSS anchor positioning to the rescue
During work on another part of the project I have learned about the new CSS Anchor Positioning API and already used it to position a popover. Sadly support has not yet landed in Firefox and Safari, but there is a polyfill available. I was really delighted when I realized that anchor positioning is actually the perfect tool to place the drop zones.
On the table header elements set
anchor-name: --header-{i+1}
to make them available as a
reference point to position the drop zones. Then render N+1 drop zones
in a separate loop with this styling:
left: anchor(--header-{i} center, 0); /* left edge of drop zone at center of --header-{i} */
right: anchor(--header-{i+1} center, 0); /* right edge of drop zone at center of --header-{i+1} */
top: anchor(--header-1 -20%); /* top edge of drop zone 20% above top of header */
bottom: anchor(--header-1 120%); /* bottom edge of drop zone 20% below bottom of header */
The second argument to the anchor()
function is a
fallback in case the anchor does not exist. Because the left anchor of
the leftmost drop zone and the right anchor of the rightmost dropzone do
not exist, we can set a fallback of 0 to style them as
left: 0
and right: 0
respectively. This means
that they will extend up to the border of the browser window, which
gives the user some margin of error when dropping a column. You could
wrap the table into a relative
positioned element to cut
off the outer drop zones before they reach the border of the browser.
I’ve also added a 20% margin above and below the table header to allow
some vertical margin of error.
Wrapping up
CSS anchor positioning makes this really elegant to write and I can’t wait for the spec to land in all major browsers. Positioning an element relative to two or more elements was simply not possible before and can be useful in very specific instances such as drag and drop reordering of columns.
All that remains now is to add a dragover indicator to show where the column would be dropped if the mouse was released at the current position and wire up the event handlers:
- When the drag handle receives
mousedown
setdraggable
on the column header - When the column header receives
dragstart
create the invisible drop zone elements. Note that Chrome will abort the drag operation immediately if you create an element where the drag started, so you must omit the two drop zones adjacent to the column that is being dragged. This does not limit functionality because dragging the column there wouldn’t change the order of the columns anyways. - When a dropzone receives
dragover
update the dragover indiciator. This indicator could be a dashed line between the two column headers where the dragged column would go or similar. - When a dropzone receives
drop
do the actual reordering of columns
More details about the event handling can be found in the drag and drop article on web.dev.