Now that we're all ready, there's no reason to bury the lede, today we're gonna talk about what those same techniques look like when taking advantage of the support of a simple base class for creating fast, lightweight web components; LitElement. And so, without further ado, here's what that looks like it all of its glory:
Well, maybe not all of its glory, more like in its one-to-one porting of the realities discussed and delivered with fully vanilla JS in the previous article. We've seen some of it before in the Declarative API section of the previous article, but it's important to revisit it now as it will form the basis for extending the elements to support the ideas discussed across the whole But, now what? section therein. So, let's work up to full glory together!
This was a large piece of any production possible code that I chose to leave out of our previous conversation for proof of concept's sake. We discussed some of the possibilities but didn't get into them, until now. The first place we'll run into an issue is with the use of slot.assignedNodes()
. You may remember we had previously been using slot.assignedElements()
, however, we want to be able to get loose text nodes as well as elements, so assignedNodes
is the way to go. Let's take a look at what the code relying on this looks like now:
projectSlot(e) { if (!e.target.assignedNodes().length) return; this.dispatchEvent(createEvent('portal-open', { destination: this.destination, content: e.target.assignedNodes(), })); }
You may also remember that when relying on ShadyDOM in a polyfilled setting there is no support for assignedNodes
, so we'll need to do some extra work in order to enable the same functionality cross-browser. How sad that literally two lines of code charge such a tax on our goals here, but not to worry we can access similar results in this context with via [...el.childNodes]
. While in most cases this would do this trick, because of the use of a <slot />
tag with no name
attribute we need to filter out a few possibly false positives before passing content on to our <portal-destination />
.
get portalContent() { const slot = this.shadowRoot.querySelector('slot'); return slot && slot.assignedNodes ? slot.assignedNodes() : this.childrenWithoutSlots; } get childrenWithoutSlots() { let nodes = [...(this.childNodes.length ? this.childNodes : [])]; nodes = nodes.filter( node => node.slot === '' || node.slot === null ); return nodes; } projectSlot() { let content = this.portalContent; if (!content.length) return; this.dispatchEvent(createEvent('portal-open', { destination: this.destination, content: content, })); }
If you're interested in following along with the above code in real life, there are several ways you can access older browsers. The nuclear option is working with tools like BrowserStack, or you could rely on one of the Virtual Machines that Microsoft offers for various versions of Internet Explorer and Edge, but my current go-to is Firefox: Extended Support Release. Firefox ESR is an enterprise-targeted release of the Firefox that is currently shipping version 60 which initially released before the v1 web components specification was supported by Firefox. It doesn't make debugging very fun, as I've not figured out how to open the dev tools, however alert()
works just fine and I've leveraged it more than I'd like to admit...
In the realm of cross-browser support, the remaining context for us to cover is applying styles to the content when it reaches the destination end of the portal. This is really where things get tricky and force us to weigh the pros and cons of various paths forward. By default LitElement
will do the work of ensuring the ShadyCSS is applied to components in a polyfilled context. ShadyCSS does the work to emulate shadow DOM based style encapsulation in browsers that do not yet support the specification natively, a list of browsers that grows shorter every day with the sun settings on IE11 and pre-Edgium Edge. It does so at the intersection of correctness and performance by writing a single scoped version of the styles targeted to the component in question into the global scope. This goes a long way towards maintaining the "styles scoped to element" contract of Shadow DOM based styles; however, it comes with two main trade-offs. The first involves not specifically addressing the "protected from external selectors" contract, which means that ALL styles from outside of your shadow DOM will have the ability to leak into your component. The second is more particularly troubling in the context of our portal-destination
definition, the styles applied to all instances of custom element's shadow DOM will have to be the same by default.
In that each piece of projected content over the lifecycle of an application could be deserving of custom styling this can prove tricky in the context we've been working so far where we apply our content directly to the <portal-entrace/>
element:
<portal-entrance destination="style-demo"> <style>button{background: red;}</style> <h1>Send This Content</h1> <p>Hello world! From my-element ${this.counter}</p> <button @click=${this.increase}>+1</button> </portal-entrance>
For the <style/>
s defined in this context to apply to the portal-destination
element, we need to do work over the top of the LitElement
implementation to correctly scope this content via the ShadyCSS polyfill. What's more, the <style/>
element would need to not be inside of the shadowRoot
of a parent element at runtime to ensure it will not be consumed for by that parent element as if those styles were meant for it. The most direct way to overcome this issue is to wrap the content that we'd like to send over the portal in a custom element:
<portal-entrance destination="destination"> <content-to-be-ported-element></content-to-be-ported-element> </portal-entrance>
However, the restrictions this places on potential use are quite prohibitive:
<style/>
elements directly into you <portal-entrance/>
's light DOM.<content-to-be-ported-element/>
.<content-to-be-ported-element/>
.While every well-defined piece of code requires a list of things you can't do with it, I feel this is a bridge too far. We should be able to dial these back a bit and allow us to ship this functionality with a little more flexibility. The main thing we are looking to address here is the ability to place <style/>
elements directly into the <portal-entrance/>
element and have those styles apply to the <portal-destination/>
element to which they are sent. Luckily, whether you are using @webcomponents/webcomponentsjs/webcomponents-bundle.js
or its slimmed down younger sibling @webcomponents/webcomponentsjs/webcomponents-loader.js
to ensure cross-browser support they will each ensure that browsers without native shadow DOM support are delivered the ShadyCSS polyfill.
The ShadyCSS polyfill supplies an API by which templates and styles can be prepared to approximate the encapsulation of the content in our similarly polyfilled shadow root from the rest of the document. We can use it to do additional work of it over the top of what is provided by LitElement
in order to ensure the same treatment of <style/>
content sent over our portal. The process involves these steps:
<style/>
tags that will be direct children on the <portal-destination/>
element. Capture both their style text (innerHTML
) for scoping and append the nodes to the template created above for preparing the DOM.<style/>
tags have been found.<portal-destination/>
element.This looks like the following in code:
get preparedProjected() { if (!this.projected) return []; if ( window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow ) { return this.projected; } let styles = []; let template = document.createElement('template'); this.projected .filter(el => el.constructor === HTMLStyleElement) .map((s) => { styles.push(s.innerHTML); template.appendChild(s); }); if (styles.length === 0) { return this.projected; } template.innerHTML = stylesHTML.join(''); window.ShadyCSS.ScopingShim.prepareAdoptedCssText( styles, this.localName); window.ShadyCSS.prepareTemplate(template, this.localName); window.ShadyCSS.styleElement(this); return this.projected .filter(el => el.constructor !== HTMLStyleElement); }
This means that our usage caveats are much more acceptable:
<style/>
element openly available for consumption by a parent component at runtime.<style/>
elements that are direct children will apply to the light DOM content of an "entrance".<style/>
elements directly in the <portal-entrance/>
light DOM will apply to all <portal-destintion/>
elements and their content, regardless of name
.This means that our usage caveats are much more acceptable:
<style/>
element openly available for consumption by a parent component at runtime.<style/>
elements that are direct children will apply to the light DOM content of an "entrance".<style/>
elements directly in the <portal-entrance/>
light DOM will apply to all <portal-destintion/>
elements and their content, regardless of name
.With these alterations, our family of portal elements is now ready for delivery cross-browser no matter the level of support those browsers have for the Shadow DOM specification. This capability came with some active trade-offs, but as they are directly in line with those that come with the ShadyCSS polyfill itself, which means they will hopefully be familiar to those working with other web components and shadow DOM tools.
When you bring this all together in an updated version of our Menu Populates Content Populates Menu Example from the previous article, it looks like the following in all its cross-browser supporting glory:
A close eye might catch the use of no-shadow
on the <html-include-with-anchors/>
elements, there is some sort of timing issue in upgrading there <portal-entrance/>
elements therein that I'm gonna have to follow up on separately.
From this baseline, we can now focus on rounding out some of the capabilities of our portal.
The ability to dynamically track the attributes of an element without any special APIs for setup is certainly one of the clearest wins of the custom element specification. Through the use of the static observedAttributes
array and the associated attributeChangedCallback
we are able to take fine-grained control over how our components react to changes declared directly in the markup describing them. That means the following code allows our newly defined custom element to react to changes in the value of the custom-attribute
attribute and store that value as a local property.
class DeclarativeElement extends HTMLElement { static observedAttributes = ['custom-attribute']; attributeChangedCallback(name, oldValue, newValue) { switch (name) { case 'custom-attribute': this.customProperty = newValue; break; } } }
Others have previously pointed out that managing ALL of your attributes and their relationship to properties in this manner can be quite tiresome, and I would agree. Not having to manually wire everything you want to track in the HTML of your custom element to related properties one at a time is a great reason to work with libraries and tooling when developing web components. Luckily, we're already committed to using LitElement
as a base class which helps us setup this relationship via its static get properties()
API. Let's take a look at how the above is achieved therein:
class DeclarativeElement extends LitElement { static properties = { customProperty: { type: String, attribute: 'custom-attribute' } } }
Notice the change from HTMLElement
to LitElement
for our class extension. That change gives us access to a static properties getter that will outline the attributes we want to hear about changes to, and we receive an extended list of options with which you can outline the relationship between the attributes and their associated properties. For our <portal-entrace/>
element, we can outline a more declarative API, like so:
class PortalEntrance extends LitElement { static properties = { destination: { type: String }, manual: { type: Boolean }, open: { type: Boolean, reflect: true }, order: { type: Number }, } }
Adding a property in this way to a LitElement
based custom element also means that changes to these properties will automatically kick off the update lifecycle of the component. In the case that these properties are used in building the DOM representation of your element, this is super helpful. However, being that none of these properties need to trigger a new render there are a couple of paths to optimizing reactive management of these attributes. We could extend these definitions to include hasChanged() { return false; }
and prevent that entirely. Or, we could separately use the shouldUpdate
lifecycle method to prevent that holistically across the component. Further, knowing that there is zero processing that goes into understanding our element's template of <slot @slotchange=${this.shouldProjectSlot}></slot>
, we can rely on lit-html
, the renderer underlying LitElement
, to efficiently discover that there are no DOM changes to be made after any of those changes and not worry about extended configuration at all. So many options towards ensuring a more performant application! To ensure that our <portal-entrance/>
elements are rendered once and then not worried about again, we'll pair the shouldUpdate
and the firstUpdated
lifecycle methods like so:
shouldRender() { return !this._hasRendered; } firstUpdated() { this._hasRendered = true; }
Here, our first update occurs unimpeded but by setting this.shouldRender() = false
as part of that first update, no further updates to the rendered shadow DOM are made.
Right about now you might be asking, "If they don't trigger a render, what do these properties even do?", and with good reason! First, let's remember that all of the DOM related to our portal is supplied as light DOM, and we use the <slot/>
element in our template to listen to changes in that content for sending across the portal, which means internally we only need to render once, as shown above. When changes in the light DOM content occur, a call to shouldProjectSlot()
will be made, which is where our component decides what to do with the DOM provided:
shouldProjectSlot() { if (!this.open) { if (!this.manual) { this.open = true; } } else if (this.manual) { this.projectSlot(); } }
The most important thing to take away from this transaction is that when manual === true
and open === true
the projectSlot()
method will be called directly allowing content placed into <portal-entrance/>
to be streamed across the portal. Otherwise, when manual === false
, open
is set to true
, which relies on the following getter/setter pair:
get open() { return this._open; } set open(open) { if (this.open === open) return; this._open = open; if (open) { this.setAttribute('open',''); this.projectSlot(); } else { this.removeAttribute('open'); this.close(); } }
Within this setter we eventually make that call to projectSlot()
in this context as well, we just make a short detour to maintain a representative state on the way there. This allows us to worry about the fewest number of entries into the projection functionality as possible while also aligning the internal API of the <portal-entrace/>
element with that available from the outside.
We'll match this with declarative updates to the API of our <portal-destintion/>
element as well. These additions will leave our static properties getter looking like the following:
class PortalDestination extends LitElement { static properties = { name: { type: String }, projected: { type: Array }, multiple: { type: Boolean }, announces: { type: Boolean }, projecting: { type: Boolean } } }
Much of these additions will be discussed in greater depth along with the features they add below, but, before we move on, notice the projecting
property. We'll use this in conjunction with the projecting
attribute as a hook for styling this component when content is being projected into it. This being purely representational of internal state, it will be helpful to prevent this from being changed from the outside. While techniques like the use of underscore prefixed or new Symbol()
based property names can support this sort of security, we can also manage this reality by only offering a setter for this value:
set projecting(projecting) { projecting = this.projected.length > 0; if (projecting) { this.setAttribute('projecting',''); } else { this.removeAttribute('projecting'); } }
Here we receive an incoming value and simply throw it away. At this time, I don't see needing this property for anything other than the styling hook, so we don't even need to cache it internally. In the updated()
lifecycle method we'll use this.projecting = 'update';
to initiate this functionality, and the setter will manage the presence of the projecting
attribute.
With our declarative API prepared, controlling the open
state and destination
of a <portal-entrance/>
becomes very straight forward. See it in action below:
Now that we are more practiced on delivering the API for our portal in a declarative manner, doing so for additional features will hopefully become less and less daunting. One piece of functionality that we've previously discussed supporting and that can benefit from a declarative API is the ability to project content from more than one <portal-entrance />
into a single <portal-destination/>
; another feature originally outlined by the Portal Vue project. We can power this with the addition of a multiple
attribute to our <portal-destination/>
element, as well as an order
attribute to our <portal-entrance/>
element. Usage might appear like this following:
<portal-entrance destination="mutliple" order="1" > <h1>Second</h1> </portal-entrance> <portal-entrance destination="mutliple" order="0" > <h1>First</h1> </portal-entrance> <portal-destination multiple name="mutliple" ></portal-destination>
In the above example, both of the <h1/>
elements will be sent to the <portal-destination/>
and due to the presence of multiple
, both will be displayed therein. However, because of the values in the order
attributes for those <portal-entrance/>
elements, the first <h1/>
will be displayed second, and the second <h1/>
will be displayed first. To make this possible, we've added the order
attribute to the static properties getter in our "entrance" element:
order: { type: Number }
With that attribute surfaced at the API level, it will then be available for delivering to our "destination" element via the portal-open
:
projectSlot() { let content = this.portalContent; if (!content.length) return; this.dispatchEvent(createEvent('portal-open', { destination: this.destination, content: content, entrance: this, order: this.order || 0, })); }
On the "destination" side, there will be a good bit more that needs to change to support this addition. Before we get into those, we'll need to add the new attribute to its properties getter:
multiple: { type: Boolean }
Once again, this allows us to receive changes to this attribute via the attributeChangedCallback
that LitElement
connects directly to a matching property. With that available in our custom element, we'll then be able to use it to make decisions on how to respond to the various events that are being listened for. Specifically, we'll change the updatePortalContent
method from being a catch-all for the most recently opened/closed <portal-entrance/>
element to a gate for managing content differently depending on the value of multiple
:
updatePortalContent(e) { this.multiple ? this.portalContentFromMultiple(e) : this.portalContentFromOne(e); }
That simple, right? Riiight.
To support both of these code paths, we'll create an intermediary map to cache the available content before flattening it into an array of arrays for pushing into our template. This means we'll create a new Map()
that will be keyed by the actual <portal-entrance/>
elements from which the content is delivered. The values will be structured as an object with both the received content, as well as the order value from the "entrance" element:
{ portal-element => { content: node[], order: number, } }
We'll build this data in response to the portal-open
event via the following method:
cacheByOriginOnOpen(e) { if (e.type !== 'portal-open') return; this.projectedByOrigin.set( e.detail.entrance, { content: e.detail.content, order: e.detail.order, } ); }
We'll use this map in the multiple === false
path of our updatePortalContent
functionality to decide whether the "destination" is currently receiving content from an "entrance" and to close that entrance before applying new content to the destination:
portalContentFromOne(e) { if (this.projectedByOrigin.size) { this.projectedByOrigin.keys().next().value.open = false; } this.cacheByOriginOnOpen(e); this.projected = e.detail.content || []; }
And, on the multiple === true
path, the map will power our ability to sort the content by the order
attribute delivered from the "entrance" and flatten the map into our expected projected
property:
portalContentFromMultiple(e) { this.cacheByOriginOnOpen(e); const batchProjected = Array.from( this.projectedByOrigin.values() ); batchProjected = batchProjected .sort((a,b) => a.order - b.order) .reduce((acc, projection) => { acc.push(projection.content); return acc; }, []); this.projected = batchProjected; }
When portal-close
is dispatched, we'll use this structure to ensure only the content in question is being returned to the closing <portal-entrance/>
element while also removing that element from the local cache before updating the portal content once again:
closePortal = (e) => { if (!this.confirmDestination(e)) return; this.returnProjectedWhenManual(e); this.projectedByOrigin.delete(e.detail.entrance); this.updatePortalContent(e); } returnProjectedWhenManual({detail: {manual, entrance}}) { if (!manual) return; const projected = this.projectedByOrigin.get(entrance); if (!projected) return; projected.content.map(el => entrance.appendChild(el)); }
In an actual application, this could exhibit a list of items for multiple selected with the <portal-destination/>
playing the role of confirmation UI, allowing it to be located anywhere on the page. In the following example, the "selected" list will appear directly next to the ten options. However, in the DOM, the two lists are in completely different branches:
Up to this point we've relied on our <portal-destination/>
elements being live and named when our <portal-entrance/>
elements come knocking with their portal-open
events. Paired with our recent addition of the manual
attribute outlined above, this seems like a fairly complete API relationship between the two elements. However, what if our "entrance" is ready to openbefore out "destination" is ready to receive? Whether through general runtime realities or as applied consciously when taking full control of your application's load process, it is feasible that you will run into a context where you intend for a <portal-destination/>
to be lying in wait when you open
a <portal-entrace/>
and it's just not there. To support this, let's add some functionality to "announce" the presence or a change of name in our "destination" element. It's a great addition to the declarative API of our elements, we can do so, while also making it opt-in, by adding an announces
attribute to our <portal-destination/>
element. While we're at it, let's also make the name
attribute reflect so that any changes we make to that value imperatively will be represented in the rendered DOM.
name: { type: String, reflect: true, }, announces: { type: Boolean, }
With LitElement
we have a couple of options as to where we'd like to react to changes in our properties. In this case, we can get all of the flexibility that we'll need by relying on the updated
lifecycle method. There we will receive a map keyed by values that have changed pointing to the previous value of those properties. This will allow us to test for changes to either announces
or name
with changes.has()
, like so:
updated(changes) { if (changes.has('announces')) { this.shouldAnnounce(); } else if ( changes.has('name') && typeof changes.get('name') !== 'undefined' ) { this.announce(); } this.projecting = 'update'; }
In the case of changes to name
, when the value is being changed (not when being set initially from undefined
) we'll immediately make a call to announce()
the presence of the <portal-destination/>
element. When it is the value of announces
that has changed we'll make a call to shouldAnnounce()
which confirms announces === true
before calling announce()
. This path is also added to the connectedCallback
so that when the element is rejoining the DOM it will also announce itself when configured to do so.
announce() { this.dispatchEvent(createEvent('portal-destination', { name: this.name, })); }
As you can see, the announce
method is powered again by Custom Events, this time the portal-destination
event. On the <portal-entrance/>
side we'll listen for that event, using a listener attached to the document
and the capture
phase of that event so that it can respond accordingly with as little interference as possible:
connectedCallback() { super.connectedCallback(); document.addEventListener( 'portal-destination', this.destinationAvailable, true ); } disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener( 'portal-destination', this.destinationAvailable, true ); this.open = false; } destinationAvailable = (e) => { if (e.detail.name === this.destination) { this.shouldProjectSlot(); } }
And now we're listening on both sides of the portal. Our already thorough API is even more complete and we've further expanded the ways we can leverage our component manages content and the way it can display throughout our application. While it's not always easy to anticipate how realities of the loading process will affect the performance of our applications, in the following demo I've artificially delayed the customElements.define()
call for the <portal-destination/>
element so that you can experience what this enables. Run the demo with the console open to follow along on the delayed timing:
With the support for style application that we added as part of our cross-browser coverage, we now have a lot of control over how we style the content that we're sending over the portal. Styles contained within child components of our <portal-entrance/>
s forwarded to our <portal-destination/>
. <style/>
tag children of those "entrances" are also forwarded to their assigned "destination", assuming that when ShadyCSS is required those elements are added after the <portal-entrance/>
's parent element's shadow DOM was initially polyfilled. However, when working with custom elements and shadow DOM we are offered an even wider array of possibilities to style our DOM.
There are some newer ways like working with Constructible Stylesheets, and the number of immediate performance benefits they bring. In concert with the adoptedStyleSheet
API, they also open an expanded set of possibilities when working within predefined style systems. There are also more common concepts that need to be addressed like CSS Custom Properties.
The way that they offer a style bridge into the shadow DOM of a custom element is really powerful. However, when physically moving DOM from one part of the DOM tree to another it can take that content out of the cascade which those custom properties rely on to be applied appropriately. With those custom properties being difficult to acquire without previous knowledge of their presence, it is tricky to find productive/performant ways to move those properties along with the content that is being sent across the portal. These concepts and more being ripe for research, a follow-up article specifically covering style acquisition and application seems appropriate, even before this one is even done.
Beyond simply porting our <portal-entrance/>
and <portal-destination/>
elements to extending the LitElement
base class, we've already done so much:
multiple
"entrances" in a single "destination"But, there is still so much to do!
Even before getting into the experimental work around supporting a more rich style application ecosystem, the most important next step is the addition of testing. Even just developing the demos for this article I found a number of edge cases that will need to be fully covered to call these components "production ready". I've done my best to fill in the holes as I wrote, but I'm sure there are things that I've missed and updates not appropriately reflected in this article. Focusing on the integration point between these two elements, there is much to be done in order to ensure future additions and refactoring do not affect the functionality we've worked on so far negatively. To that end, I will be spending some quality time with Testing Workflow for Web Components before getting back to you all with even more explorations on the other side of the portal. Try not to close the "entrance" while I'm gone.
Thanks for reading !
Originally published on https://dev.to
#javascript #html #html5 #web-development