DEV Community

Westbrook Johnson
Westbrook Johnson

Posted on • Updated on

Your Content in Shadow DOM Portals

This is cross posted from Medium, thanks for looking again if you saw it there 🙇🏽‍♂️ and welcome if this is your first time. I've PR'd dev.to to have the Stackblitz embeds point to specific files, so hopefully that has this post at feature parity to that one soon. 🤞

Where are they Now?

I recently saw a link to PortalVue@2.1.0 on Twitter and, as I often am when viewing really cool work attached to a specific framework, I was driven to think, what would that look like as a web component. In particular Vue already feels a bit like web components to me (maybe it’s the hyphens in their tag names, maybe it’s the closer to HTML templates, maybe it’s the way they build to web components directly from their CLI, who can be sure), so it makes it even easier for me to imagine porting something over. Needless to say, I got into the work, and found some interesting early success that I’d love to share with you. In the process, I also ran into a number of questions around API design, scope of work, and doneness in which you’ll hopefully be interested in taking part.

What is a Portal?

For those of your who chose not to checkout PortalVue above, and have otherwise not worked with portals in that past, the basic concept is as follows. When you have some content or component(s) in one part of your site that you’d like to display in another location while still having that content be bound to the data and functionality of the initial location, a portal allows you to project that content into the second location. While concepts like position: absolute might make this appear trivial from the onset, that approach can be hindered by layout realities (e.g. overflow: hidden/auto, transform: translate..., z-index: ..., etc. all interfere with this idea). A portal allows you not to worry about these complexities by giving you a receiver local to the initial content that will project the desired content to the destination of your choosing while managing any issues that might come about in the process. A fairly common pattern that this helps to manage is opening/interacting with a modal dialog. I’ll discuss that as well as some other uses I’ve thought about below.

What a Portal Isn’t

Maybe “isn’t” is the wrong word for this sections, in that Portals as specified by the WICG ARE “a proposal for enabling seamless navigations between sites or pages.” So in reality, that IS what a Portal is. However, for the purposes of this conversation I will stick to the React definition of a Portal which is to “provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.” For those of you looking for information on the other, sorry. I do hope you’ll stick around for the rest of my thoughts, anyways. And, for those of you that now have a longer reading list for wanting to know about the WICG specification, you’re welcome!

The Means

Before getting into actual code, I wanted to introduce a couple of concepts that I planned to leverage in making a web native portal; Shadow DOM and Custom Events. Shadow DOM and its slot API is the OG web native portal. Shadow DOM allows your to hide away the internals of your component and decorate any content supplied in the Light DOM. When mixed with the slotchange event available on <slot/> elements (the locations where Light DOM will be placed in your Shadow DOM) the capabilities you are provided with are perfectly suited for receiving and maintaining content to be sent across a portal. Custom Events allow you to pass arbitrary information along with your event via the detail entry in the CustomEventInit dictionary (e.g. new CustomEvent('name', {detail: 'Info Goes Here'})). In conjunction with event.composed, which allows your event to pass through Shadow DOM barriers, transporting the content in question around the DOM appears well within reach.

Working from these decisions there are a number of different ways that I can see achieving the functionality of our portal. Because of this almost overwhelming amount of flexibility, I’ve decided that rather than thinking that I could in someway find the best way all by myself that I’d try to get something close to the simplest way together and then discuss the options for how to harden the approach as part of this article. To that end, I hope you find excitement or question in the outline of the code that follows, because I want to hear about it. Hopefully much of the questions you have will also be things that I’ve thought about, but please do keep me honest and comment below/tweet me @westbrookj with your thoughts. Once the approach is locked down a little more, I’m looking forward to publishing theses elements to NPM so the whole community can benefit from them.

The Portal

Like any good portal, ours will have a portal-entrance, which will mark the location where our content will be bound to data and functionality, and a portal-destination, which will mark our display location. Our portal-entrance will have a destination property that addresses it to a specific instance of our portal-destination with a matching name property. The connection between the two will be wrought with Custom Events and be dispatched from a slotchange event. This is triggered on any change in the content of a <slot/> element and can made available to our portal-entrance element as follows:

constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = this.render();
    const slot = this.shadowRoot.querySelector('slot');
    slot.addEventListener(
        'slotchange',
        this.projectSlot.bind(this)
    );
}
render() {
    return `
        <style>
            :host {
                display: none;
            }
        </style>
        <slot></slot>
    `;
}

From the event object we’ll have access to e.target.assignedElement() which outlines the content of the slot as provided from the light DOM, which is important being e.target.children does not become available on this element:

this.dispatchEvent(
    new CustomEvent(
        'portal-open',
        {
            composed: true,
            detail: {
                destination: this.destination,
                content: e.target.assignedElements(),
            }
        }
    )
);

Two important things to note about the event that is being dispatched here:

  1. The event is composed that means that it will be able to pass through Shadow DOM boundaries. This is an important and easily overlooked part of working with events in conjunction with Shadow DOM. A shadowRoot is a document-fragment and without composed: true your event will run its entire lifecycle locked to that scope.
  2. The event does not bubble. In the context of our portal we should be able to rely on it being opened any time we place content into portal-entrance. To be sure that the events dispatched in response to those changes won’t have their propagation stopped early the listener in the portal-destination will be placed on the document and do its work during the capture phase, making it the first to have access to the event.

For those of you (like myself) that don’t use the capture phase often (or maybe ever), this is the first of the growing number of options baked into the third argument in our addEventListener. You can implicitly wire your listener to this phase of the event via:

document.addEventListener(
    'portal-open',
    this.acquireContent,
    true
);

The above harkens back to a time when capture was the only functionality available to the third argument, however that reality being of the past our portal code will prefer to outline the use of capture explicitly via:

document.addEventListener(
    'portal-open',
    this.updatePortalContent,
    {
        capture: true
    }
);

By default we will manage whether our content is being projected into its destination primarily by whether the portal-entrance element is connected to the DOM or not. This means that we can take advantage of the disconnectedCallback() lifecycle method to dispatch the portal-close event which will tell our portal-destination that content is no longer being projected into it.

disconnectedCallback() {
    this.close();
}
close() {
    document.dispatchEvent(
        new CustomEvent(
            'portal-close',
            {
                composed: 1,
                detail: {
                    destination: this.destination
                }
            }
        )
    );
}

Here this functionality is presented in the close() method which means this functionality will also be available directly on our portal-entrance element for calling imperatively as needed. It’s also dispatched on the document to ensure that it is hung on an element that will remain in the document event when removing the portal-entrance element or any number of its ancestors as part of a larger change to the DOM tree.

Putting all of that together, our portal-entrance element looks like the following:

portal-entrance.js in all its 46 lines of glory.

The Destination

We’ve already noted that our portal-destination will listen for the content being projected into it from the document via the capture phase of the portal-open event, a la:

document.addEventListener(
    'portal-open',
    this.updatePortalContent,
    {
        capture: true
    }
);

It is important that we manage the addition and removal of this event in parallel with the elements lifecycle so as to not leave any zombie events. For this we can rely on the connectedCallback() and disconnectedCallback() to manage addEventListener and removeEventListener, respectively. But, what do we actually do when we updatePortalContent?

First, we’ll updatePortalContent from the portal-open event, but only if it is meant for this destination. By the current approach of a managing listeners via connectedCallback() and disconnectedCallback(), the idea that there can be multiple portal-destinations is managed by each of those destinations managing themselves. Due to this reality each destination will hear all of the portal-open events, and then will need to determine which are meant for it by checking the destination of the event in its detail object. Matching events currently have their propagation stopped so that the event doesn’t continue along the capture phase down the DOM tree before caching the projected content into the element.

acquireContent(e) {
    if (e.detail.destination !== this.name) return;
    e.stopPropagation();
    this.projected = e.detail.content;
}

From there a getter/setter pair is leveraged to manage side-effects to the change of the value of this.projected:

get projected() {
    return this._projected || [];
}
set projected(projected) {
    this._projected = projected;
    this.project();
}
styles() {
    return ':host{display: contents}';
}
conditionallyAppendStyles() {
    if (this.shadowRoot.adoptedStyleSheets) return;
    let style = document.createElement('style');
    style.innerHTML = this.styles();
    this.shadowRoot.appendChild(style);
}
project() {
    this.shadowRoot.innerHTML = '';
    this.conditionallyAppendStyles();
    this.projected.map(el => this.shadowRoot.appendChild(el));
}

And, with that we’re pretty much done. At this point there is no .clone()ing of the nodes, so the actual DOM and any bindings that would have occurred on it at its initial location will be preserved in its new location. Binding strategies that save those nodes, or save locations in those nodes for future updates will maintain access to them directly for future updates and responding to DOM events, while strategies that rebuild the DOM will trigger a new slotchange event starting the porting process over again. You’ll also notice back in our portal-entrance element, that it doesn’t dispatch portal-open events when it is empty, so as to prevent not cloning the ported elements from triggering a secondary event that would remove the content from both locations. All that’s really left is clean up.

Our portal-destination element hangs a listener for the portal-close event on the document, this also points into the updatePortalContent method, but this time with no actual content to apply. This will “set” the value of projected to be undefined but its getter will ensure that this falsy data falls back to an empty array.

Subsequently, the side effects from setting projected are run again and the element content is reset, done and done. When you put that all together, it looks like:

Our Portal in Action

Seeing is believing, so take a look now at the actual code in action (if you’ve had the patience not to look already):

When you click “Toggle Projection” a portal-entrance is added/removed from the element bound by a red outline in the top/left corner of the page, and its content will be ported to a portal-destination element in the bottom/right corner of the page bound in green. Once your content is projected you can increment the counter and its value will persist across toggles. You will also be able to reset the bound data at its source by using the “Reset Count” button.

This use case is certainly contrived, and some real examples and use cases are featured below. But, first, I’d like to talk about some questions that I have about the current approach and some next steps particularly around getting this into a shape that others might want to use. After that we can revisit some of the ideas presented by both VuePortal and React Portals, and maybe even think of some new ones and/or some use cases empowered by clever combinations with other web components…

But, now what?

Mirrored Listening

Currently the API contract states that a portal-destination has to be available and named at the time a portal-entrance with that destination is connected to the DOM. Does this go far enough? Should there also be mirrored listeners on established portal-entrance to redistribute content when a destination is subsequently connected? There would seem to be as many usage patterns that would want to leverage this series of events as there are that leverage the current pattern. Research should go into what those patterns might look like and whether built in or extended support for those features are more appropriate in the case that adding them makes sense.

Once you start to think about late bound destinations, the door is also opened for binding to multiple destinations. This takes the conversation in much more structurally signification directions as we’ve relied on there being only one instance of the DOM elements passed across the portal, and the ability to move that element around the DOM tree to this point. Were it to make senses to port the same content to multiple locations, then the binding and cloning of that content would require significant alteration to the form that it currently employs. While I could certainly see ways that this might come to pass, I’m not sure it makes sense in the scope of work the current portal exists in. Change my mind!

Multiple Entrances

Related to the idea of multiple destinations, one feature that PortalVue currently supports that I think would be a solid addition is support for multiple portal-entrance elements delivering their content to the same destination. Adding a multiple attribute to portal-destination in a similar vein to that found in the select element immediately brings to mind ways to support things like “multi-select” input fields with a sort of “chips” UI, or breadcrumb aggregations. This approach would most likely want for an order attribution similar to what PortalVue employs, but there could be something interesting to be had by relying on DOM or interaction order that would be worth thinking about as well.

Cross Browser Support

Now that you’ve seen the basics of my approach to making a portal with shadow DOM and custom elements the most important next step is ensuring that the functionality is cross browser compatible. It’s easy to write this off as the bane of web components, but in actuality browser support is quite high. Shadow DOM currently enjoys 87% support natively, custom elements are listed at 86%, and with the polyfills that you may have noticed in our demos both of those number approach full coverage.

<script
    src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js"
></script>

Where this approach to portals starts to have issue is in its use of the slot.assignedElements(). As the polyfill for Shadow DOM is actually pretty large and slow, we work with a rough approximation called Shady DOM instead. Shady DOM doesn’t fully recreate the Shadow DOM specification but mimics the important parts of its feature sest. One of the main things that this leaves out is slot.assignedElements(). In a more complex component this would be where FlattenedNodesObserver from the Polymer library might become necessary, however the simplicity of having only one slot and no other DOM to worry about in our portal-entrance allows us to getaway with using [...el.children] to get a value similar to what would be available from slot.assignedElements().

What this won’t capture out of the box is style encapsulation. Research into the right balance of brining the Shady DOM library into the portal-destination element to scope any style tags that might come across the portal will be needed. I’d hope that we could make use of some or all of the styles delivered to their destination but the costs of requiring extra code to support that or the requirement for some sort of BEM-like naming convention are outweighed by their benefits are as yet unclear.

Even More Styles

After the idea of getting styles in polyfilled browsers is either solved or avoided, some of the super powers available in more modern browsers, and particularly those supplied via stylesheet adoption open the gates to a large number of options that I think deserve exploration. You can see in our code currently where the smallest use of the API is being made:

if (this.shadowRoot.adoptedStyleSheets) {
    const sheet = new CSSStyleSheet();
    sheet.replaceSync('a { color: red; }');
    this.adoptedStyleSheets = [sheet];
}

With this in mind, when transporting styles from entrance to destination we could leverage this API to make delivering those styles easier. However, the more interesting question is whether it makes sense to travel up the DOM tree and acquire styles from parent shadow roots for transporting to the portal-destination as well. Immediately a number of questions around things like light DOM with it’s ::slotted() style API, or managing CSS Custom Property application in the alternate DOM tree extension come to mind, but being able to make certain guarantees in this area would make the pattern even more interesting.

Declarative API

Right now our portal-entrance and portal-destination elements rely on two attributes collectively; destination and name. However, we’ve already talked about a couple of additions that might be good to make to that family. On portal-entrance having an opened attribute to outline when the content of the portal is being distributed to its portal-destination could prove a really useful way to manage this work without having to add and remove the portal-entrance element every time. We’ve also talked about the idea of adding order to the entrance as well to manage the delivery of multiple pieces of content to a single destination. To support that, there’s also the idea of adding multiple as an attribute of portal-destination. When it was just two attributes that were powering these elements it made sense to read that data directly out of the attributes, and not worry about much reactivity to that data, however the more attributes we add the more boilerplate and wiring is required to mange that influx of data. It may become worth while to rely on a more formal base class to our custom elements. At the cost of a handful of KB we could rely on LitElement to manage some of that for us. It doesn’t save but a few lines of JS now, but as our API grows it may be more and more useful, particularly with its helpers around Adopted Stylesheets and Shady DOM/CSS support. However, performant rendering is really the super power of LitElement and we’re only doing the smallest amount of that in portal-destination at this time. It’s possible that it would be overkill, but seeing whether things like directives (e.g.cache) could save our elements work over the lifecycle of a page. Here’s an early one for one prototype of that conversion, it certainly makes demonstrating the portal easier, if nothing else:

Examples

Content Populates Menu

A really nice example from the VuePortal project is the ability to port sub-navigation or context content into an aside from the body of the main content area:

This does a great job of ensuring that related content lives together in your markup/content delivery, but isn’t required to live together in your actual UI. As a systems’ content grows the likelihood that the aside content is not the same shape increases, so having it managed with the main content, rather than as an interpretation of that data makes a lot of sense. I’ve replicated this approach with a static page (no navigation) below:

In this case, with the dynamic page switching taken away the idea that you already have full control of the HTML and can place it wherever you please begins to raise question as to the value of this pattern.

Menu Populates Content Populates Menu

This sort of content throwing might not make since in the case that the whole HTML response is coming down at the same time. However, combine this approach with more dynamically acquired content powered by something like html-include from Justin Fagnani and you start to have something a little more interesting. With this pattern no only can your content populate your menu, but your menu can populate your content.

You’ll notice that on page navigation the href of the header links are being captured to populate the src of html-include elements, which are sent via portal to the main content area based on which was most recently opened. Each of the pages that are subsequently loaded by those includes contains a content specific secondary navigation that is sent via portal to the correct part of the page for display. It’s still a pretty raw usage, but I’m interested in the simplicity of the following in architecting something like a blog or brochureware site:

<header>
    <nav>
        <portal-link>
            <a href="page1.html">Page 1</a>
        </portal-link>
        <portal-link>
            <a href="page2.html">Page 2</a>
        </portal-link>
        <portal-link>
            <a href="page3.html">Page 3</a>
        </portal-link>
    </nav>
</header>
<aside>
   <nav>
        <portal-destination name="navigation"></portal-destination>
   </nav>
</aside>
<main>
   <portal-destination name="main"></portal-destination>
</main>

Notice the content from the first page is manually copied into this structure in a way that emulates server side rendering. With only a little more effort to ensure the the server response ships the content specific to each page on load these web components would be server side rendered.

Modal

The OG example of why you’d want content to escape the DOM tree is to display it in a modal. Here’s a super plain example of managing a modal in the content of a portal-entrance and portal-destination pair of elements if for nothing else than to prove we’ve got this use case covered:

What’s your favorite?

There are a good number of cases where this sort of content portal proves its use in an application. Even if only in the way that it supports the modal use case this approach makes a very common technique much easier than it could be. That being so, I’m looking forward to continuing research into this area. With a little more structure around the usage APIs and patterns the ability to port content around the page with a pair of custom elements might also prove valuable to others. If you’re interested in one of the patterns above, or have something else in mind that a portal might support, I’d love to hear about it. The more voices behind the settlement of APIs around this technique the stronger and more useful it will be across the community. Also, the more voices that I hear interested in this technique the sooner I’m likely to finish... let’s go home team!

Top comments (0)