Un composant dynamique commun

Avant de construire nos composants personnalisés, il est important de formaliser leur fonctionnement.

On va donc créer une class commune pour structurer tout cela et faciliter le développement futur :

export class MyBaseElement extends HTMLElement {
	constructor() {
		super()
		this.attachShadow({ mode: 'closed' }) // Notre Shadow DOM n'a pas besoin d'être modifié de l'extérieur
		this.render()
	}

	connectedCallback() {
		this.update() // Le composant est ajouté au DOM, on affiche le contenu
	}

	attributeChangedCallback(name, oldValue, newValue) {
		this[name] = JSON.parse(unescape(newValue)) // On considère tous nos attributs comme du JSON
		this.update() // On met à jour le contenu du composant pour ré-évaluer le template
	}

	update() {
		this.shadowRoot.innerHTML = this.render() // On évalue le template
	}

	render() {
		return '' // Notre composant est vide par défaut
	}
}
tools/my-base-element.js

Nos composants héritant de cette class seront donc automatiquement mis à jour en cas de changement de valeur d’un attribut ! :D

Et puisqu’on y est, gérons aussi les styles !

export class MyBaseElement extends HTMLElement {
	get styles() { // Les styles ne devraient pas être trop dynamiques, on peut donc définir un simple _getter_
		return '' // Chaque composant pourra définir ses propres styles
	}

	// […] le contructeur, des callbacks…

	updateStyles() {
		const style = document.createElement('style')
		style.textContent = this.styles
		this.shadowRoot.appendChild(style)
	}

	update() {
		// […] le contenu actuel
		this.updateStyles() // On ajoute cette ligne
	}

	// […] le reste de la class
}
tools/my-base-element.js

Si vous avez besoin d’un composant plus puissant, je vous recommande LitElement qui mettra à jour les valeurs de façon plus efficace et permet d’utiliser des templates plus complets.

Une étiquette pour les templates

Comme on utilisera des template literals dans nos composants, il peut être utile de faciliter l’injection de valeurs.

On a d’ailleurs préparé le terrain en évaluant le JSON dans les attributs dans la méthode attributeChangedCallback plus haut.

Automatisons donc tout cela dans nos templates avec un tag dédié :

export function html(strings, ...values) {
	const l = strings.length - 1
	let html = ''

	for (let i = 0; i < l; i++) {
		const s = strings[i]
		let v = values[i]

		if (Array.isArray(v)) {
			v = v.join('')
		}
		if (typeof v === 'object') {
			v = escape(JSON.stringify(v)) // La valeur à injecter est un objet, on la convertit alors en JSON et on l'échappe pour l'utiliser dans du HTML
		}
		html += s + v // On ajoute notre valeur au morceau correspondant
	}
	html += strings[l] // On n'oublie pas le dernier morceau

	return html // On retourne la chaîne complète avec les valeurs injectées
}
tools/custom-html.js

Ou pourra alors injecter des objets directement dans un template :

const valeur = 42
const objet = { foo: 'bar' }

const template = html`<mon-composant valeur="${valeur}" attribut="${objet}"></mon-composant>`

Si vous avez besoin d’un système de template plus complet (ou que vous utilisez LitElement) je vous recommande de jeter un œil à lit-html, qui fournit une fonction du même style bien plus puissante

Nos premiers composants interactifs

C’est parti pour commencer à avoir une application digne de ce nom !

La racine de l’application

C’est le composant de base, celui qui va s’occuper de charger le plus gros de l’application et d’en gérer le fonctionnement global

On va commencer léger, en affichant nos listes (qui sont pour l’instant au très grand nombre de… zéro 😁) et un formulaire pour en créer une (via un composant dédié)

import { MyBaseElement } from '../tools/my-base-element.js'
import { html } from '../tools/custom-html.js'
import { STYLE_COMMON } from '../tools/styles.js'

 // On importe les composants utilisés dans le template
import { NewTodoListElement } from './new-todolist.element.js'
import { TodoListElement } from './todolist.element.js'

export class AppElement extends MyBaseElement {
	get styles() {
		return `
			${STYLE_COMMON}
			:host {
				display: flex;
				flex-wrap: wrap;
				width: 100%;
				justify-content: center;
			}

			.todolist {
				flex: 0 0 18rem;
				max-width: calc(100% - 2rem);
				margin: 1rem;
				height: 25rem;
				background-color: white;
				color: black;
				box-shadow: 0 3px 5px rgba(0, 0, 0, .3);
				border-radius: .5rem;
				overflow: hidden;
			}
		`
	}

	constructor() {
		super()
		
		this.listes = []
	}

	render() {
		return `
		${this.listes && this.listes.length ?
			this.listes.map(liste => html`<section is="todo-list" class="todolist" liste="${liste}"></section>`) :
			``
		}
		
		<section is="new-todo-list" class="todolist"></section>`
	}
}
window.customElements.define('my-app', AppElement)
elements/app.element.js

Notez l’utilisation du pseudo-sélecteur :host pour styliser notre composant, au lieu d’utiliser le nom de la balise : le CSS étant lui-aussi séparé de celui de la page.

La création de liste

import { MyBaseElement } from '../tools/my-base-element.js'
import { html } from '../tools/custom-html.js'
import { STYLE_COMMON, STYLE_FORM } from '../tools/styles.js'

import { TodoList } from '../models/todolist.js'

/**
 * @event newListName — Le nom de la liste à créer
 */
export class NewTodoListElement extends MyBaseElement {
	get styles() {
		return `
			${STYLE_COMMON}
			${STYLE_FORM}
			:host {
				display: flex;
				flex-direction: column;
				justify-self: stretch;
				align-self: stretch;
			}
			h1 {
				margin: 0;
				padding: .5rem 1rem;
				font-size: 1.5rem;
				text-align: center;
				border-bottom: 1px solid #f1f1f1;
			}
			h1 label {
				display: block;
				white-space: nowrap;
				overflow: hidden;
				text-overflow: ellipsis;
			}

			form {
				flex: 1 0 auto;
				display: flex;
				flex-direction: column;
				padding: 0;
				text-align: center;
				font-weight: bold;
				align-items: center;
				justify-content: space-between;
			}
			form::before {
				content: '';
				height: 0;
				flex: 0 0 auto;
			}
			input {
				justify-self: center;
				flex: 0 0 auto;
				width: 75%;
				border-bottom: 1px solid #f1f1f1;
			}
			input:hover,
			input:focus {
				border-color: #c4c4c4;
			}
			::-moz-placeholder {
				color: #444;
				font-style: italic;
				text-align: center;
			}
			::-webkit-input-placeholder {
				color: #444;
				font-style: italic;
				text-align: center;
			}
			button {
				flex: 0 0 auto;
				width: 100%;
				border-top: 1px solid #f1f1f1;
			}
		`
	};

	constructor() {
		super()
	}

	connectedCallback() {
		super.connectedCallback()

		// On attend que le HTML soit évalué par le navigateur avant d'appliquer les écouteurs
		setTimeout(() => {
			const form = this.shadowRoot.getElementById('formulaire-creation-liste')

			this.shadowRoot.addEventListener('submit', this.creerListe.bind(this))

			this.shadowRoot.addEventListener('input', () => {
				const input = this.shadowRoot.querySelector('form input[type="text"]')
				const btn = this.shadowRoot.querySelector('form button[type="submit"]')

				btn.disabled = !(input.value && input.value.length)
			})
		}, 0)
	}

	creerListe(event) {
		event.preventDefault()

		const input = this.shadowRoot.getElementById('input-ajout-liste')

		const task = new TodoList(input.value)

		input.value = ''
	}

	render() {
		return html`
		<h1>
			<label for="input-ajout-liste">Créer une liste</label>
		</h1>

		<form id="formulaire-creation-liste">
			<input type="text" id="input-ajout-liste" aria-invalid="true" aria-label="Créez une nouvelle liste de tâches à faire" placeholder="Ex. : Liste de courses">
			<button type="submit" disabled>Créer</button>
		</form>`
	}
}
window.customElements.define('new-todo-list', NewTodoListElement, { extends: 'section' })
elements/new-todolist.element.js

Remonter les informations

Maintenant que les informations voyagent dans un sens, il serait temps de les faire passer dans l’autre sens pour les propager… avec des événements !

Par exemple pour la création d’une liste :

const liste = new TodoList(input.value); // On crée une nouvelle liste

const newListEvent = new CustomEvent('new-list', {
	bubbles: true,
	composed: true,
	detail: liste // On transmet notre nouvelle liste dans l'événement
})

this.dispatchEvent(newListEvent) // On transmet l'événement
elements/new-todolist.element.js

On peut alors récupérer l’événement plus haut :

this.renderRoot.addEventListener('new-list', (event) => {
	this.listes.push(event.detail); // On récupère la liste dans l'événement
	this.update(); // On re-calcule le template pour afficher la nouvelle liste
})
elements/app.element.js
Félicitations !

Et voilà, vous avez maintenant une belle todo-list ! 😍

Vous pouvez retrouver le code complet sur GitHub.

Vous pouvez également tester ma version en ligne gratuitement et librement.