Web Components a Beginner Friendly Guide
Some of my notes about getting started with Web Components including how to create a Web Component as well as style them and general built in methods
Render A Web Component In HTML
A Web Component as far as html is concerned is just a custom html tag.
It can have regular html attributes as well as custom attributes.
They can be custom, or extend existing elements.
Here is an example of a custom web component vs a custom web component that
is extending an HTMLAnchorElement
<am-my-web-component class="can-have-class" text="can have custom attribute" is-visible="true">This content is the default slot</am-my-web-component>
<a is="am-custom-link">My Custom Link</a>
The difference in defining the two using Javascript is this:
customElements.define("am-my-web-component", MyWebComponent)
vs
customElements.define('am=custom-link', CustomLink, {extend: 'a'})
Extending theHTMLAnchorElement
requires us to defind which html element
we are extending when we define the component in javascript.
The Anatomy Of A Web Component
- It should extend some type of built-in html i.e.,
HTMLElement
orHTMLAnchorElement
- Must call
super()
to init the parent constructor class - Must attach shadow
this.attachShadow({mode: "open"})
- Must define:
customElements.define('am-my-custom', CustomElement)
orcustomElements.define('am-my-extended', MyExtended, extends: 'a')
class ButtonToggle extends HTMLElement {
constructor() {
super() // must be called to call constructor on parent class being
extended
this._state = {}
this.template = document.createElement('template')
this.template.innerHTML = `<div><slot></slot>Maybe After Text</div>`
this.attachShadow({mode: "open"}) // can be closed
this.shadowRoot.appendChild(this.template.content.cloneNode(true))
// clone template and append to shadow root
}
// how to remove slots if needed
_removeSlots() {
this.shadowRoot
.querySelector("slot")
.assignedNodes()
.forEach((node) => {
node.remove();
});
}
}
connectedCallback use to make DOM manipulations and work with component
connectedCallback()
is a builtin method that is the entrypoint of the web
component. This is where your initalized state is going to happen and when
you can make updates to the shadow DOM and add any event listeners.
How to use the attributeChangedCallback in Web Components
This is a two parter:
you need to set up the
attribubuteChangedCallback(name,oldVal, newVal)
method.You need to use a built in static method observedAttribues. This is setup like this:
static get observedAttributes() { return ['text', 'class', 'nameOfAttribute'] }
Then in
attributeChangedCallback
you can use a switch statement to handle business logic:attributeChangedCallback() { if (oldVal === newVal) return false switch (name) { case "text": // do something break } }
Performing Cleanup When Component Is Unmounted
disconnectedCallback() {
// cancel fetch request?
// remove event listeners
this._myComponent.removeEventListener("mouseenter", this._myEvent);
this._myOtherComponent.removeEventListener("mouseleave", this._otherMyEvent);
}
Styling Custom Components
:host
:host
can be used to give your web component default styling
:host {
color: blue;
}
:host(.warning) {
background: red;
color: white;
}
:host-context
Only has support in chromium right now and most likely not going to be added to safari or firefox any time soon.
Can style the web component differently depending on the parent.
:host-context(p) {
border: 1px dotted red;
}
::slotted
::slotted
can help provide styles to the <slot></slot>
.
attributeChangedCallback() vs MutationObserver
The attributeChangedCallback is a nice interface for listening for attribute changes.
One can also use MutationObserver
to handle attribute changes:
// openObserver gets this and aliases it to modal and returns a new
MutationObserver
openObserver() {
let modal = this;
return new MutationObserver(function (mut) {
mut.forEach((m) => {
if (m.type === "attributes") {
modal.toggleActive();
}
});
});
}
The callback function is just looping for each mutation and checking if the type is ‘attributes’. If it is, it is calling the necessary method.
In the connectedCallback
it can be instantiated like so:
this.myMutationObserver = this.openObserver()
.observer(this, {
attribute: true,
})
In the disconnectedCallback
you might need to remove this:
this.myMutationObserver.disconnect()
Named Slots
It is possible to use multiple slots.
<div>
<header><slot name="title">Default Title Here</slot></header>
<section><slot name="body">Default Body Here</slot></section>
</div>
and to use in html:
<am-my-wc>
<h1 slot="title">My Title</h1>
<p slot="body">My Content</p>
</am-my-wc>
Get Slot Content
const slots = this.shadowRoot.querySelectorAll("slot");
slots[1].addEventListener("slotchange", (event) => {
console.dir(slots[1]);
// slots[1].assignedNodes()
});
Custom events
handleClick(event) {
switch (event.target.id) {
case "cancelBtn":
this.hide();
const cancelEvent = new Event("cancel", {
bubbles: true, // bubbles up
composed: true, // composed means it can leave the shadow dom
});
event.target.dispatchEvent(cancelEvent);
break;
case "okayBtn":
this.hide();
const okayEvent = new Event("confirm");
event.target.dispatchEvent(okayEvent);
break;
default:
}
}
events() {
this.cancelBtn.addEventListener("click", this.handleClick.bind(this));
this.confirmBtn.addEventListener("click", this.handleClick.bind(this));
}
connectedCallback() {
this.events();
}
This over-complicates it in object oriented programming because you can
usually just use this.dispatchEvent(myCustomEvent)
Note: you still most likely need to at bubbles: true
and composed: true
so the event bubbles up and also can leave the shadow dom.
A Complete Example Of A Web Component
// modal.js
class AmModal extends HTMLElement {
constructor() {
super();
this.template = document.createElement("template");
this.pointerEventType = "none";
this.opacity = 0;
this.template.innerHTML = `
<style>
#backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0,0,0,0.75);
z-index: 10;
pointer-events: ${this.pointerEventType};
opacity: ${this.opacity};
transition: all 350ms;
-webkit-transition: all 350ms;
-moz-transition: all 350ms;
}
#modal {
background: #fff;
border-radius: 3px;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: 100;
position: fixed;
top: 35%;
left: 50%;
transform: translate(-50%,-50%);
width: 50%;
pointer-events: ${this.pointerEventType};
-webkit-transition: all 350ms;
-moz-transtion-all: all 350ms;
transition: all 350ms;
}
:host([open]) {
#backdrop,
#modal {
opacity: 1;
pointer-events: all;
}
}
:host([open]) #modal {
top: 50%;
}
header {
padding: 1rem;
border-bottom: 1px solid #fff;
}
header h2 {
font-size: 1.25rem;
}
::slotted(h2) {
margin-bottom: 0;
overflow-wrap: break-word;
}
#main {
padding: 1rem;
}
#actions {
border-top: 1px solid #ccc;
padding: 1rem;
display: flex;
justify-content: flex-end;
}
#actions button {
margin: 0 0.25rem;
}
</style>
<div id="backdrop">
<div id="modal">
<header>
<slot name="title">Default Header</slot>
</header>
<section id="main">
<slot></slot>
</section>
<section id="actions">
<button id="cancelBtn">Cancel</button>
<button id="okayBtn">Okay</button>
</section>
</div>
</div>
`;
this.isOpen = false;
this.attachShadow({ mode: "open" });
this.shadowRoot.appendChild(this.template.content.cloneNode(true));
this.cancelBtn = this.shadowRoot.getElementById("cancelBtn");
this.confirmBtn = this.shadowRoot.getElementById("okayBtn");
const slots = this.shadowRoot.querySelectorAll("slot");
slots[1].addEventListener("slotchange", (event) => {
//console.dir(slots[1].assignedNodes());
});
}
toggleActive() {
if (this.hasAttribute("open")) {
this.isOpen = true;
this.shadowRoot.getElementById("backdrop");
this.render();
return;
}
this.isOpen = false;
this.shadowRoot.getElementById("backdrop");
this.render();
}
open() {
if (!this.hasAttribute("open")) this.setAttribute("open", "");
}
hide() {
if (this.hasAttribute("open")) this.removeAttribute("open");
}
handleClick(event) {
event.target.focus();
switch (event.target.id) {
case "cancelBtn":
this.hide();
const cancelEvent = new Event("cancel", {
bubbles: true,
composed: true,
});
this.dispatchEvent(cancelEvent);
break;
case "okayBtn":
this.hide();
const okayEvent = new Event("confirm", {
bubbles: true,
composed: true,
});
this.dispatchEvent(okayEvent);
break;
default:
}
event.target.blur();
}
handleKeyPress(e) {
if (e.key === "Escape") {
this.hide();
}
if (e.key === "Enter") {
this.open();
}
}
events() {
this.cancelBtn.addEventListener("click", this.handleClick.bind(this));
this.confirmBtn.addEventListener("click", this.handleClick.bind(this));
this.shadowRoot
.getElementById("backdrop")
.addEventListener("click", this.hide.bind(this));
document.addEventListener("keydown", this.handleKeyPress.bind(this));
}
connectedCallback() {
this.events();
}
disconnectedCallback() {
document.removeEventListener("keydown", this.handleKeyPress);
this.cancelBtn.removeEventListener("click", this.handlClick);
this.confirmBtn.removeEventListener("click", this.handleClick);
}
}
customElements.define("am-modal", AmModal);
// modal.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="modal.js" defer></script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 2rem;
font-family: sans-serif;
}
</style>
</head>
<body>
<am-modal>
<h2 slot="title">My Custom Slot</h2>
<p>Show Me The Money!</p>
</am-modal>
<button>Show Details & Confirm</button>
</body>
<script>
const [confirmButton] = document.getElementsByTagName("button");
const [modal] = document.getElementsByTagName("am-modal");
modal.addEventListener("confirm", () => {
console.log("confirmed");
});
modal.addEventListener("cancel", () => {
console.log("cancelled...");
});
confirmButton.addEventListener("click", function (e) {
modal.open();
});
</script>
</html>