Handling web components and drag and drop with event.composedPath()

The web platform gives us the tools to make web components, shadowRoots, and the drag and drop api work happily together. We just have to pull the right levers.

A deep shadowRoot chain being dragged on mobile with my shim.
Justin Ribeiro
7 min read Filed in Web

The HTML5 Drag and Drop API is one of those APIs on the web platform that I find people either despise or begrudgingly tolerate. It’s not that said API is particularly tricky to use or implement, but it’s non-existence on mobile without polyfills or shims is painful to get correct without a lot of code, or if you’ve tried to use them with shadowRoots and web components, the situation can be doubling confusing.

Most of that confusion comes from the examples or libraries that folks use that are really only looking at event.target, which people find just don’t work. The reason becomes pretty clear if you look at any web component with some depth of a shadowRoot: event.target gives you to the top most element you’re hoving over and that’s probably not what you want. What to do? event.composedPath() to the rescue.

Events, events, everywhere

The problem that folks run into is that the using event.target is not going to give you the depth you want against the delegation of said event. It’ll tell us the element on which the event occurred, which in most cases will be our top most web component. Similarly, event.curentTarget is going to reference where our handler has been attached. While this is a bit of a simplification, the general gist is that we won’t exactly have what we need when it comes to things like ShadowRoots.

Instead, what we want to use is event.composedPath(). This gives us the array of nodes and objects where our listeners are going to be invoked, including within the scope of shadowRoots (as long as the mode is not closed).

As an example, let’s look a screenshot to see what exactly a DragEvent targets when web components are involved:

Justin Ribeiro

In Box #1 above, you can see that the event.target is set to a main-view web component. However, we also see that our event.composedPath() has main-view and a lot of other nodes. Box #2 actually points to the a drop-list component in our path which is what we want to operate against.

How do we work with this? Let’s look a few examples.

The examples

Note, all examples are available in on the demo site and in the justinribeiro/html5-dragdroptouch-shim repo.

To help illustrate this concept let’s mock-up at two simple vanilla web components, drop-list-item and drop-list.

drop-list-item is going to be a simple thing we can drag around, so it needs a few things:

  1. It needs to know it’s a draggable
  2. It needs to tell us when it is be dragged
  3. It should listen for other things being dragged over it (in case maybe we wanted to sort a list or something)

To handle this, we write up the basics:

customElements.define("drop-list-item", class extends HTMLElement { constructor() { super(); this.addEventListener("dragstart", this.__dragStart.bind(this)); this.addEventListener("dragend", this.__dragEnd.bind(this)); this.addEventListener("drop", this.__dragEnd.bind(this)); this.addEventListener("dragover", this.__dragOver.bind(this)); this.addEventListener("dragleave", this.__dragLeave.bind(this)); // You _cannot_ just set draggable without the string "true"; // it will not work this.setAttribute("draggable", "true"); const shadowRoot = this.attachShadow({ mode: "open" }); shadowRoot.innerHTML = ` <style> :host { display: block; padding: 0.5rem; background-color: #f1f1f1; margin: 0.25rem; } :host([dragging]) { background-color: hotpink; color: #fff; } </style> <slot></slot> `; } // Important: we need to understand who's dragging so we can // grab in the dropzone __dragStart(event) { event.dataTransfer.setData("text/html", "test"); this.setAttribute("dragging", ""); } __dragEnd() { this.removeAttribute("over"); this.removeAttribute("dragging"); } __dragOver() { if (this.hasAttribute("dragging")) { this.removeAttribute("over"); } else { this.setAttribute("over", ""); } } __dragLeave() { this.removeAttribute("over"); } });

As you can see above, we’re not doing a lot of heavy lifting at all. We set some attributes based on the events so we can apply some style and as you’ll see, so we can figure out who we might need to grab in our drop zone.

How does this relate to a dropzone? Let’s look at drop-list and see what magic is under the hood:

customElements.define( "drop-list", class extends HTMLElement { constructor() { super(); this.addEventListener("drop", this.__dzDropHandler.bind(this)); this.addEventListener("dragover", this.__dzDragover.bind(this)); this.addEventListener("dragleave", this.__dzDragLeave.bind(this)); // For the sake of the demo, we just set this here this.setAttribute("dropzone", "move"); const shadowRoot = this.attachShadow({ mode: "open" }); shadowRoot.innerHTML = ` <style> :host { display: block; border: 2px dotted grey; min-height: 100px; } :host([active]) { border: 2px dotted red; } </style> <slot></slot> `; } /** * Functionality for the list container once and item has been dropped * @param {object} event drop */ __dzDropHandler(event) { event.preventDefault(); this.appendChild(this.__draggingElement); this.removeAttribute("active"); this.__draggingElement = null; } __dzDragLeave() { this.removeAttribute("active"); } /** * Functionality for the list container once we are hover on the list * @param {object} event drop */ __dzDragover(event) { event.preventDefault(); this.setAttribute("active", ""); let found; if (!this.__draggingElement) { // find what we're looking for in the composed path that isn't a slot found = event.composedPath().find((i) => { // usually we can just grab event.composedPath()[0], but let's be safe if (i.nodeType === 1 && i.nodeName !== "SLOT") { return i; } }); if (found) { // find where we are deep in the change const theLowestShadowRoot = found.getRootNode(); this.__draggingElement = theLowestShadowRoot.querySelector( "[dragging]" ); } else { this.__draggingElement = document.querySelector("[dragging]"); } } } } );

In the component above, we can see that our dragOver event handler does some lifting, taking into account the event.composedPath(), grabbing the node (which is usually the first item in the array) and then looking through that node to see if we have a dragging item.

Since it’s a bit hard to visualize, the video I made below shows the behavior in action across a wide range of examples (many of which comes from questions I’ve received lately).

All the examples are available all on the demo site as well if you’d like to give them a spin.

Making it work on mobile

That’s all fine and dandy Justin, but what about mobile you say? None of the polyfills work and what is a person to do?

This entire walk-through stems from that very question. Most of the polyfills or various shims don’t use event.composePath() and as such and this became a pain in my side (I did not have this lying around in my private toolbelt as this hasn’t been a huge ask over the years). Alas, we need something.

If you happened to follow the the examples above or watched the video, you’ll note that they live in justinribeiro/html5-dragdroptouch-shim repo. That repo is my opinionated shim that polyfills HTML5 drag and drop support on mobile devices with Event.ComposedPath() support. While this is in large part an ES Modules refactor of Bernado’s dragdroptouch polyfill (which deserves the bulk of the love by the way), this version differs in two keys areas:

  1. Re: finding the draggable. Uses event.composedPath() to allow use to hunt for draggables within open ShadowRoots

  2. Re: finding the dropzone. Uses event.composedPath() to find the target shadowRoot, then uses DocumentOrShadowRoot.elementFromPoint to locate our dropzone target.

This allows it to be more readily be used with ShadowDOM and web components, which is my primary use case for it to be honest. It does however work fine without web components use case for mobile as well.

It’s early days for said shim, but I’ve used on couple projects without issue. It is available on NPM and if you find bugs, do let me know.

Go further

Now I’m not saying this is the end all be all; there is more you could do (I don’t go into list sorting for instance) and you surely would firm up those web components (ala…ditch those events on disconnect and be a good component citizen). Hopefully this gives you a spring board that using the bare metal web platform and the tooling it gives you is not the scary complicated beast some folks make it out to be. We have the tools, you just have to hone in on the right one’s for the job.

So get out there, explore the web platform, and build some cool stuff. 🎉🎉🎉