This article is the second in my series on building Web Components, and builds on what I started in Building A Loading Modal Component. If you haven't seen that one yet, you may want to give it a quick read to catch yourself up.
Making our component more flexible
So we have a working component. Great. But, as written, it isn't flexible enough to be of use as a general purpose tool.
We'll tackle this in a couple steps. In this article, we'll dive into three aspects of Web Components:
- Slots allow us to put content inside our component
- Attributes allow us to configure our component in the HTML tag
- Properties allow us to interact with our component in JavaScript
In an upcoming article, we'll discuss how to alter the styling of the component. Patience is a virtue.
First round of improvements
Let's review our "proof of concept" page:
<html>
<head>
<script src="eh-loading-modal.js"></script>
</head>
<body>
<main>some content here</main>
<eh-loading-modal></eh-loading-modal>
<script>
const loader = document.getElementsByTagName('eh-loading-modal')[0]
loader.show()
setTimeout(() => loader.hide(), 5000)
</script>
</body>
</html>
As you'll recall from the previous article, we load the Component via <script>
tag, add it to our page via <eh-loading-modal>
tag, and then show (and later hide) it via the JavaScript at the end of the page.
Our Component, so far, is:
window.customElements.define('eh-loading-modal',
class EhLoadingModal extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" })
this.shadowRoot.innerHTML = `
<style>
#loading {
background-color: gray;
height: 100%;
left: 0;
opacity: 0;
position: fixed;
top: 0;
visibility: hidden;
width: 100%;
z-index: 1;
}
#loading.visible {
opacity: 97%;
visibility: visible;
transition: visibility 0s linear 0s, opacity .25s 0s;
}
#loading .message {
height: 100%;
margin: 20px 2px;
padding: 2px 16px;
position: relative;
text-align: center;
top: 40%;
vertical-align: middle;
}
</style>
<div id="loading">
<div class="message">
<p>Loading... </p>
</div>
</div>
`
}
show() {
this.shadowRoot.getElementById('loading').classList.add("visible")
}
hide() {
this.shadowRoot.getElementById('loading').classList.remove("visible")
}
})
Slots
I'm sure you noticed that the component, when shown, always puts the word "Loading..." in the center of the page on a gray background. Swell. But maybe you want something else, like "Please Wait" or a nice animated image. It sure would suck to have to re-write our component every time we wanted a different message, especially since that's EXACTLY what we're trying to avoid doing in the first place. Slots solve this problem.
Let's adjust our component code to add a "slot" in place of the paragraph which contains the original message. In the constructor()
we'll update:
<p>Loading...</p>
to read:
<slot>Loading...</slot>
The <slot>
tag identifies a place where we can slot-in (get it?) other content. If you change the component as above, and re-load your POC page, nothing will have changed.
BUT, when we update our POC page with:
<eh-loading-modal>
<h2>Please Wait</h2>
<p>We're loading your report. This could take a moment.</p>
<img src="/static/spinner.webp"/>
</eh-loading-modal>
we get a different result. Have fun playing with that, keeping in mind that any valid HTML can go inside those <eh-loading-modal>
tags, and it will render much as you'd expect.
Named slots
It's important to note that slots can be named and then referenced by name. Let's take an example building on our work so far. If we were to update our component's innerHTML
like this:
...
<div class="message">
<slot>Loading... </slot>
<slot name="footer">(this is a footer)</slot>
</div>
...
we'd now have a default "footer" on our loading page.
Then, to overwrite the content of that footer, we'd update our POC page as:
<eh-loading-modal>
<h2>Please Wait</h2>
<p>We're loading your report. This could take a moment.</p>
<img src="/static/spinner.webp"/>
<p slot="footer">This will go in the footer, not the main message area</p>
</eh-loading-modal>
The thing to remember is the <slot>
is name
d, whereas the element to go into the slot is slot
ted.
Attributes
Attributes, part of the HTML specification, are values applied to tags. For example, in <input type="checkbox" checked>
there are two attributes:
type
has the value "checkbox"checked
has the implicit value "checked"
Maybe now you're thinking
Why not use an attribute rather than a slot to set the content of the loading page?
First, the value of an attribute is a string, which would limit the loading message to text, without the ability to add in images and such.
Beyond that, it's a design choice. I find this:
<eh-loading-modal content="Wait For It..."></eh-loading-modal>
far less appealing and understandable what I've shown above.
Let's add an Attribute!
Currently, our loading modal doesn't show until we tell it to with JavaScript. What if we want to build a page which immediately shows a loading message, and that message goes away when the page is ready to show whatever data it's been fetching in the background? Why then, we'd use an Attribute in our HTML, to wit:
<eh-loading-modal visible>
<h2>Please Wait</h2>
<p>We're loading your report. This could take a moment.</p>
<img src="/static/spinner.webp"/>
<p slot="footer">This will go in the sticky footer, not the main message area</p>
</eh-loading-modal>
See the visible
attribute added above? That's going to tell our component it should start off being visible.
Inside our component, the outermost div
either has or doesn't have a class of visible
to control its visibility. By default, this div
does NOT have this class applied. We want our component to react to the presence of the visible
attribute by adding the visible
class to it when initially rendered.
connectedCallback
We have to make a small change to our component to support this new attribute. So far, we've been doing most of our work in our component's constructor()
but to enable support for attributes, we're going to refactor much of that work into the connectedCallback()
function. The browser calls this function every time it "mounts" a node into the DOM, at which point its attributes are available. Before then, they aren't.
window.customElements.define('eh-loading-modal',
class EhLoadingModal extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" })
}
connectedCallback() {
const visible = this.hasAttribute('visible') ? "visible" : ""
this.shadowRoot.innerHTML = `
...
}
Now we have a const
which is EITHER "visible" or "" (empty), depending on the existence of the visible
attribute. Let's use it in the innerHTML
:
this.shadowRoot.innerHTML = `
<style>
...
</style>
<div id="loading" class="${visible}">
...
Let's update our POC page to take advantage of this new behavour:
<html>
<head>
<script src="eh-loading-modal.js"></script>
</head>
<body>
<main>some content here</main>
<eh-loading-modal visible>
<h2>Please Wait</h2>
<p>We're loading your report. This could take a moment.</p>
</eh-loading-modal>
<script>
const loader = document.getElementsByTagName('eh-loading-modal')[0]
setTimeout(() => loader.hide(), 5000)
</script>
</body>
</html>
Properties
What if we want to know if the component is currently visible? Properties to the rescue. Properties are component values available to your page's scripts.
PLEASE NOTE: Properties often look like Attributes, and they can be confused, but they are different things for (often overlapping) purposes.
Let's add this code to the component:
get visible() {
return this.hasAttribute("visible")
}
Now, you can go to your browser's console and type:
document.getElementsByTagName('eh-loading-modal')[0].visible
You should get back true
EVEN IF THE MODAL ISN'T CURRENTLY SHOWING! Why? Because we added the visible
attribute to the HTML, so it's ALWAYS there.
How do we address this? We could:
- ignore the attribute and just maintain some internal state
- inspect the classList of the controlling
div
- add and remove the attribute like native DOM nodes do
If you toggle an HTML checkbox, you'll notice the checked
attribute is added to or removed from the DOM node. We should probably emulate that behavior, so let's go with that last one.
Relating Properties to Attributes
We need to connect our visible
property to our visible
attribute, and then use that connection to drive our component's operation.
First, we'll add a "setter" along side our "getter" for visible
:
get visible() {
return this.hasAttribute("visible")
}
set visible(v) {
if (v) {
this.setAttribute("visible", "")
} else {
this.removeAttribute("visible")
}
}
Now, in the console you can type document.getElementsByTagName('eh-loading-modal')[0].visible = true
to add the attribute, and document.getElementsByTagName('eh-loading-modal')[0].visible = false
to remove it.
And we should probably update show()
and hide()
to manipulate the attribute, since that's now how we want to effect the behavior:
show() { this.setAttribute("visible", "") }
hide() { this.removeAttribute("visible") }
But this still doesn't complete the job, because just toggling the attribute on the node doesn't cause the component to DO anything. We need to make that attribute "observed" and then attach a callback to the component which implements the intended behavior. Let's add:
static get observedAttributes() {
return ["visible"]
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'visible':
if (newValue === null) {
this.shadowRoot.getElementById('loading').classList.remove("visible")
} else {
this.shadowRoot.getElementById('loading').classList.add("visible")
}
break
}
}
The browser calls attributeChangedCallback()
every time one of the attributes indicated by observedAttributes()
is changed.
So when our visible
attribute is added or removed, attributeChangedCallback()
is called with the attribute name, the previoius value of the attribute, and the new value.
Better, but more to do
So now we have a loading modal component which we can make visible by default via HTML attribute, fill with arbitrary content in the HTML, and interrogate with JavaScript to see if it's visible.
That's really nice. But wouldn't it be great to alter the colors, fonts, and layout? Next time, we'll add those capabilities and discuss some design decisions which impact how useful a component will become.