Back to the Front-end: Exploring the Future of the Umbraco UI (Part 4 - Web Components)
Matt Brailsford
Posted on October 21, 2022
If there is one fundamental cornerstone technology of the new back office UI, it really has to be Web Components. Everything from sections to property editors will be based around the Web Component concept so it's worth getting to know them.
About Web Components
The main principal of Web Components is "encapsulation". Web Components allow you to encapsulate complex behaviours and user interactions behind your own custom defined DOM elements.
Under the hood, this is achieved by 3 main technologies.
- Custom Elements - A JavaScript API for defining your own DOM elements
- Shadow DOM - A JavaScript API for defining a shadow, or private, DOM tree to render your UI on in a way that is rendered visually to the screen, but inaccessible to external code / styling, keeping your UI private
-
HTML Templates - 2 markup elements
<template>
and<slot>
for defining reusable markup templates for dynamically generated UI, and areas of your component where users can pass in additional markup to be rendered within your component.
A Web Component Blueprint
The general workflow of creating a Web Component would look like the following (Paraphrased from MDN):
Create a class in which you specify your web component functionality,
Register your new custom element using the
CustomElementRegistry.define()
methodIf required, attach a shadow DOM to the custom element using
Element.attachShadow()
method, adding child elements, event listeners, etc., to the shadow DOM using regular DOM methods.If required, define an HTML template using
<template>
and<slot>
. Again use regular DOM methods to clone the template and attach it to your shadow DOM.Use your custom element wherever you like on your page, just like you would any regular HTML element.
Custom Elements
Custom Elements give us the capability to create new HTML tags. This is achieved by calling the define()
method on the CustomElementRegistry
instance available in the window accessor window.customElements
.
class MessageBox extends HTMLElement {
// Define behavior here
}
window.customElements.define('message-box', MessageBox);
Once defined we can use this custom element like any other DOM element
<message-box></message-box>
Properties / Attributes
You can make your component configurable by exposing properties that can have their values set via attributes on your custom elements DOM tag. For example, we could expose a kind
property on our component like so:
class MessageBox extends HTMLElement {
// Set the "kind" property
set kind(option) {
this.setAttribute("kind", option);
}
// Get the "kind" property (default to 'info')
get kind() {
return this.hasAttribute("kind")
? this.getAttribute("kind")
: 'info';
}
}
Which can then be configured in the markup as follows:
<message-box kind="success"></message-box>
Lifecyle Hooks
A custom element can define special lifecycle callbacks for running code during several key moments of its existence:
-
constructor()
: Called when an instance of the element is created or upgraded. Useful for initializing state, setting up event listeners or creating Shadow DOMs. -
connectedCallback()
: Called every time when the element is inserted into the DOM. Useful for running setup code, such as fetching resources or rendering. -
disconnectedCallback()
: Called every time the element is removed from the DOM. Useful for running clean up code (removing event listeners, etc.). -
attributeChangedCallback(attributeName, oldValue, newValue)
: Called when an attribute is added, removed, updated, or replaced. Also called for initial values when an element is created by the parser or upgraded. -
adoptedCallback(oldDocument, newDocument)
: Called when the element has been moved into a new document.
The first 3 callbacks will likely be the most used callbacks of your components.
We can update our example component to use the connectedCallback()
method to render the core markup for our component when it is inserted into the main DOM.
class MessageBox extends HTMLElement {
...
// Called when inserted into the DOM
connectedCallback() {
this.innerHTML = `<div>
<h3>Message Title</h3>
<p>Message Body</p>
</div>`;
}
}
If we were to look at the rendered markup in dev tools for our component, we would see the following output:
<message-box kind="success">
<div>
<h3>Message Title</h3>
<p>Message Body</p>
</div>
</message-box>
Shadow DOM
A key problem with the output above is that it will be affected by styles and code defined outside of the component. To solve this we can use the Shadow DOM.
The role of the Shadow DOM is to simply provide us with a private DOM tree on which to render our UI that is isolated from external influences.
We create a Shadow DOM by calling the attachShadow()
method of our root level element and than interact with the returned element in the exact same way as you would any other DOM element.
If we were to update our example to use the Shadow DOM, it would then look as follows:
class MessageBox extends HTMLElement {
...
// Called when inserted into the DOM
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `<div>
<h3>Message Title</h3>
<p>Message Body</p>
</div>`;
}
}
Now if we look at the rendered markup in dev tools for our component, we would see the following output:
<message-box kind="success">
#shadow-root (open)
<div>
<h3>Message Title</h3>
<p>Message Body</p>
</div>
</message-box>
Whilst this looks pretty similar, we can now trust that the markup inside our component won't be affected by any external styles and nore will the inner elements by accessible by code, protecting our component and ensuring it looks and functions exactly how we designed it to.
HTML Templates
A hard coded message in a message box is not a very useful component, so it would be useful to be able to control what text we display in the message box.
This could be exposed as properties / attributes, but this would only accept simple strings. A more useful message box could accept HTML and allow more complex content to be displayed in the box.
To do this we can use a HTML templating feature called slots. Slots allow us to define a section in our component that will be externally supplied.
For our example, we could expose two slots, one for the message title and one for the message body. We do this by using the <slot>
tag inside our components markup and give each one a unique name.
class MessageBox extends HTMLElement {
...
// Called when inserted into the DOM
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `<div>
<h3><slot name="title">Default Message Title</slot></h3>
<p><slot name="body">Deafult Message Body</slot></p>
</div>`;
}
}
We can then pass our markup the inner contents to our slots by using the slot
attribute on an element definding without our custom component like so:
<message-box kind="warn">
<span slot="title">My Message Title</span>
<span slot="body">My Message Body, <a href="https://google.com">find out more</a></span>
</message-box>
NB I haven't outlined the use to the
<template>
tag here as I don't think it will be that big a feature in our web components from an Umbraco perspectiev, especially when we start to look at Lit in a future blog post. If you'd like to learn about the<template>
tag though, be sure to read the MDN article on Using Templates and Slots.
Scoped Styles
The final feature of the Shadow DOM that we will look at it is that of scoped styles.
By rendering in our own isolated DOM tree we also get our own isolated style context so we can ensure a) we aren't affected by any external styles and b) our styles don't affect anything outside of our component.
class MessageBox extends HTMLElement {
...
// Called when inserted into the DOM
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `<style>
#msg-box {
padding: 10px;
border-style: solid;
border-width: 2px;
border-radius: 5px;
font-family: sans-serif;
}
#msg-box > h3 {
margin: 0 0 10px;
padding: 0;
font-size: 16px;
}
#msg-box > p {
margin: 0;
padding: 0;
font-size: 12px;
}
.msg-box--success {
border-color: #059669;
background-color: #a7f3d0;
}
.msg-box--warn {
border-color: #eab308;
background-color: #fef08a;
}
.msg-box--info {
border-color: #2563eb;
background-color: #bfdbfe;
}
</style>
<div id="msg-box" class="msg-box--${this.kind}">
<h3><slot name="title">Default Message Title</slot></h3>
<p><slot name="body">Deafult Message Body</slot></p>
</div>`;
}
}
With our styles defined, we can now control the appearence of our message box by simply changing the kind attribute between one of info
, warn
and success
.
Info
<message-box kind="info">
<span slot="title">Information</span>
<span slot="body">Did you know that...</span>
</message-box>
Warning
<message-box kind="warn">
<span slot="title">Warning!</span>
<span slot="body">Something has gone wrong</span>
</message-box>
Success
<message-box kind="success">
<span slot="title">Hurrah!</span>
<span slot="body">Everything went swimmingly</span>
</message-box>
Our Complete Example Web Component
Checkout the JSFiddle below for a fully working example of our web component built during this post.
Conclusion
There is defanately more you can do with web components, but what I've tried to outline here are the likely core features that most people are going to need to pick up early. The rest can be learnt as and when it's needed, but I hope this at least shows that they aren't that complicated and even things like the Shadow DOM are easy to understand.
Additional Resources
Posted on October 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 21, 2022