Après avoir vu les principes des web components on va passer à quelque chose de concret en réécrivant notre application de liste de tâches qu’on avait codé de façon classique dans cet article. Ça sera donc la version 3 de cette liste de tâches ! On va conserver la plupart les des fonctionnalités et on aura donc pas mal de code commun. Par contre j’ai abandonné le tableau pour une liste, en effet les tableaux sont très tatillons concernant les balises et ne supportent pas qu’on leur injecte un composant. Le code complet se trouve dans ce plunk. Vous pouvez aussi récupérer le code complet dans ce ZIP.
Les composants
Quand on décompose une application en composants il faut faire des choix, si possibles logiques. Combien de composants ? Quel partage des tâches ? Des réponses à ces questions découlera d’écrire plus ou moins de code avec plus ou moins de facilité.
Pour cette application j’ai opté pour 6 composants :
Détaillons un peu ça :
- todo-app :
- wrapper de l’application
- saisie d’une nouvelle tâche
- todo-alert :
- affichage de l’alerte de tâche déjà existante
- coordination des autres composants
- todo-line :
- affichage de la liste des tâches
- suppression d’une tâche
- marquage d’une tâche
- modification d’une tâche
- todo-list :
- mémorisation des tâches et de leur état
- coordination des composants
- todo-filters :
- affichage des filtres
- changement de filtre
- todo-footer :
- affichage nombre de tâches actives
- affichage bouton de purge
Il va falloir aussi prévoir la communication entre les composants. Dans le sens parent enfant avec des attributs ou propriétés, et dans l’autre sens avec des événements.
Organisation du code
Le fait d’utiliser des composants permet une bonne organisation du code. J’ai opté pour cette solution :
Un classique index.html avec un fichier de style associé. Un dossier composant app avec 2 sous-dossiers pour reprendre l’ogranisation des composants.
Pour chaque composant un fichier index.js de base, un fichier view.js pour les éléments visuels, et accessoirement un fichier model.js pour les données (uniquement pour le composant todo-list) :
Ainsi le code est bien organisé !
index.html et style.css
Commençons par le plus simple avec la page de base en HTML et son style. Étant donné qu’on utilise des composants emboîtés on va avoir une page de base très légère :
<!doctype html> <html lang="fr"> <head> <meta charset="utf-8"> <title>Ma liste de tâches V3</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <link rel="stylesheet" href="https://unpkg.com/papercss@1.6.1/dist/paper.min.css"> <link rel="stylesheet" href="style.css"> <script type="module" src="app/index.js"></script> </head> <body> <br> <div class="paper container"> <h1 class="shadow border border-primary">Ma liste de tâches V3</h1> <todo-app></todo-app> </div> </body> </html>
J’utilise encore le framework PaperCSS. Le chargement du Javascript se fait à cette ligne :
<script type="module" src="app/index.js"></script>
Le point d’entrée du code se situe dans le composant wrapper todo-app. le composant est ensuite inséré avec cette ligne :
<todo-app></todo-app>
On n’a besoin d’aucun attribut.
Au niveau du style juste quelques règles déjà vues dans la précédente version de la liste de tâches avec un petit complément pour la liste :
button, .paper-btn { float: right; margin: 0 0 0 5px; } h1 { text-align: center; margin-top: 0.2em; } .form-group { text-align: center; margin-top: 1em; padding: 8px; } .form-group .paper-radio { display: inline-block; margin-right: 1em; } input { width: 60%; } li { border-top: 1px dashed #d9d9d8; line-height: 1.5; padding: 8px 8px 18px 8px; }
Et c’est réglé, tout le reste va se passer dans les composants.
Le composant todo-filters
Commençons par le composant le plus léger todo-filters.
Au niveau du fichier index.js on a ce code :
import html from './view.js'; export default class TodoFilters extends HTMLElement { connectedCallback() { this.innerHTML = html(this.filter); this.querySelector('div').addEventListener('click', e => { if(e.target.matches('input')) { this.filter = e.target.value; this.dispatchEvent(new CustomEvent('filter', { bubbles: true })); } }) } set filter(value) { this.setAttribute('filter', value); } get filter() { return this.getAttribute('filter'); } } if (!customElements.get('todo-filters')) { customElements.define('todo-filters', TodoFilters); }
On importe le fichier view.js qui contient le HTML :
export default (filter) => ` <div class="form-group"> <label for="all" class="paper-radio"> <input type="radio" name="filter" id="all" value="tous" ${ filter === 'tous' ? 'checked' : '' }> <span>Tous<span> </label> <label for="actifs" class="paper-radio"> <input type="radio" name="filter" id="actifs" value="actifs" ${ filter === 'actifs' ? 'checked' : '' }> <span>Actifs<span> </label> <label for="completed" class="paper-radio"> <input type="radio" name="filter" id="completed" value="finis" ${ filter === 'finis' ? 'checked' : '' }> <span>Finis<span> </label> </div> `;
On voit que l’attribut filter (tous, actifs ou finis) du composant sert à déterminer quel bouton radio doit être actif.
Et on met en place une écoute du click sur les boutons radio pour envoyer l’information du bouton cliqué au composant parent sous la forme d’un événement filter, en l’occurrence todo-list :
this.querySelector('div').addEventListener('click', e => { if(e.target.matches('input')) { this.filter = e.target.value; this.dispatchEvent(new CustomEvent('filter', { bubbles: true })); } })
Dans le détail de l’événement on transmet la valeur du bouton cliqué.
On n’a pas besoin de préciser la valeur du filtre parce qu’on a la propriété filter qui peut être lue.
Le composant todo-footer
Ce composant est léger lui aussi.
Au niveau de la vue il est décomposé en deux : un wrapper et les éléments visuels :
const wrapper = () => '<div class="row flex-edges"></div>'; const html = (number, btn) => ` <div><span class="badge">Reste ${ number }</span></div> ${ parseInt(btn) ? '<button class="btn-small btn-warning">Purger tâches finies</button>' : '' } `; export { wrapper, html }
Cette séparation est nécessaire pour pouvoir accrocher un événement parce que la partie visuelle va être régulièrement régénérée et il faudrait raccrocher à chaque fois l’événement.
Voici le code complet de index.js :
import * as view from './view.js'; export default class TodoFooter extends HTMLElement { connectedCallback() { this.innerHTML = view.wrapper(); this.reset(); this.querySelector('div').addEventListener('click', e => { if(e.target.matches('button')) { this.dispatchEvent(new CustomEvent('purge', { bubbles: true })); } }) } get number() { return this.getAttribute('number'); } set number(value) { this.setAttribute('number', value); this.reset(); } get btn() { return this.getAttribute('btn'); } set btn(value) { this.setAttribute('btn', value); this.reset(); } reset() { this.querySelector('.row').innerHTML = view.html(this.number, this.btn); } } if (!customElements.get('todo-footer')) { customElements.define('todo-footer', TodoFooter); }
On a le chargement de la vue, la mise en place du wrapper de la vue. Pour les éléments visuels on a une fonction :
reset() { this.querySelector('.row').innerHTML = view.html(this.number, this.btn); }
Le composant a deux attributs (et les propriétés associées) :
- number : le nombre de tâches actives
- btn : un booléen qui nous dit si on doit afficher le bouton de purge (dans le cas où il existe au moins une tâche finie)
Le composant todo-line
Ce composant est composé comme les autres :
On a deux éléments dans la vue :
const html = (text, complete) => ` <li>${ complete ? `<del>${ text }</del>` : text} <button class="btn-small btn-danger">Supprimer</button> ${ complete ? '<button class="btn-small demarquer">Démarquer</button>' : '<button class="btn-small marquer">Marquer</button>' } </li> `; // Affichage input const edit = (text) => `<input id="edit" type="text" value="${ text }"></input>`; export { html, edit }
- le HTML de base avec deux attributs pour le texte et le marquage
- le HTML pour l’édition du texte
Le Javascript est un peu plus chargé :
import * as view from './view.js'; export default class TodoLine extends HTMLElement { constructor(text, complete) { super(); this.text = text; this.complete = complete; // Ecoute pour alerte texte déjà existant this.addEventListener('alert-danger', () => this.alert(true)); } connectedCallback() { this.init(); } init() { this.innerHTML = view.html(this.text, this.complete); const li = this.querySelector('li'); li.addEventListener('click', e => { if(e.target.matches('button')) { // Clic sur bouton suppression tâche if(e.target.matches('.btn-danger')) { this.sendEvent('delete'); // Clic sur bouton toggle tâche } else { this.sendEvent('toggle'); } } }) li.addEventListener('dblclick', () => { this.edit(); }) } set text(value) { this.setAttribute('text', value); this.init(); } get text() { return this.getAttribute('text'); } set complete(value) { this.setAttribute('complete', value); } get complete() { return this.getAttribute('complete') === 'true'; } // Passage en édition de la tâche edit() { this.innerHTML = view.edit(this.text); const input = this.querySelector('input'); input.focus(); input.addEventListener('blur', () => this.init()); input.addEventListener('keydown', e => { // Changement du texte if(e.key === 'Enter') { this.sendEvent('update', { old: this.text, new: e.target.value }); // Sortie de l'édition } else if(e.key === 'Escape') { this.init(); } }); } // Envoi événement sendEvent(name, detail = null) { this.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: detail })); } } if (!customElements.get('todo-line')) { customElements.define('todo-line', TodoLine); }
En effet ce composant a un peu de travail :
- affichage de la liste des tâches
- suppression d’une tâche
- marquage d’une tâche
- modification d’une tâche
L’action la plus délicate concerne la modification du texte d’une tâche. l’entrée se fait par l’écoute d’un double clic :
li.addEventListener('dblclick', () => { this.edit(); })
Qui déclenche la fonction edit :
edit() { this.innerHTML = view.edit(this.text); const input = this.querySelector('input'); input.focus(); input.addEventListener('blur', () => this.init()); input.addEventListener('keydown', e => { // Changement du texte if(e.key === 'Enter') { this.sendEvent('update', { old: this.text, new: e.target.value }); // Sortie de l'édition } else if(e.key === 'Escape') { this.init(); } }); }
On commence par changer l’aspect avec une zone de saisie :
this.innerHTML = view.edit(this.text);
On donne le focus à la zone :
const input = this.querySelector('input'); input.focus();
On prévoir la perte du focus :
input.addEventListener('blur', () => this.init());
La fonction init recrée la ligne de départ.
On attend après l’action du clavier :
input.addEventListener('keydown', e => { // Changement du texte if(e.key === 'Enter') { this.sendEvent('update', { old: this.text, new: e.target.value }); // Sortie de l'édition } else if(e.key === 'Escape') { this.init(); } });
Si on a Enter alors on informe le composant parent en lui envoyant la nouvelle valeur.
Si on a Escape alors on régénère la ligne d’origine.
Le composant todo-list
Là c’est le gros morceau de l’application.
Déjà on a le modèle qui est pratiquement celui qu’on a déjà mis en place dans cet article :
// Dictionnaire let tasks = new Map(); const setStorage = tasks => localStorage.setItem('TASKS', JSON.stringify(Array.from(tasks))); const getStorage = () => JSON.parse(localStorage.getItem('TASKS')); // Initialisation à partir du storage const init = () => { const values = getStorage(); if(values) { tasks = new Map(values); } }; // Actualisation du storage const save = () => setStorage(tasks); // test existence tâche const hasTask = key => tasks.has(key); // Ajout d'une tâche const add = (key, value) => { // On ajoute la tâche tasks.set(key, value); // Actualisation du local storage save(); }; // Suppression d'une tâche const del = key => { tasks.delete(key); // Actualisation du local storage save(); }; // Mise à jour d'une tâche const update = (key, value) => { tasks.set(key, value); // Actualisation du local storage save(); }; // Retour valeur à partir d'une clé const getValue = key => tasks.get(key); // Changement clé const changeKey = (key, newKey) => { // Transformation dictionnaire en tableau const elements = Array.from(tasks); // Changement de la clé elements[elements.findIndex(t => t[0] === key)][0] = newKey; // Transformation en dictionnaire tasks = new Map(elements); // Actualisation du local storage save(); }; // Filtrage const getFiltered = filter => { return filter === 'tous' ? tasks : new Map(Array.from(tasks).filter((task) => task[1] === (filter === 'finis'))); }; // Purge les tâches finies const clean = () => { tasks = getFiltered('actifs'); save(); }; // Nombre de tâches restantes const checkStill = () => tasks.size - getFiltered('finis').size; // Nombe de tâches finies const checkComplete = () => getFiltered('finis').size; // Nombre de tâches const checkTasks = () => tasks.size; export { init, add, del, update, getFiltered, getValue, changeKey, clean, checkComplete, checkTasks, hasTask, checkStill };
Je ne reparle donc pas de tout ce code qui est destiné à gérer les données.
Au niveau de la vue on a ce code tout simple :
export default (page, number, btn) => ` <ul></ul> <todo-filters filter="${ page }"></todo-filters> <todo-footer number="${ number }" btn="${ btn }"></todo-footer> `;
Le script principal est un peu plus chargé :
import html from './view.js'; import * as tasks from './model.js'; import TodoFooter from './footer/index.js'; import TodoFilters from './filters/index.js'; import TodoLine from './line/index.js'; export default class TodoList extends HTMLElement { constructor() { super(); this.page = window.location.hash.substr(1); if(this.page == '') this.page = 'tous'; tasks.init(); // Ecoute suppression de tâche this.addEventListener('delete', e => this.handleDelete(e)); // Ecoute changement marquage this.addEventListener('toggle', e => this.handleToggle(e)); // Ecoute des filtres this.addEventListener('filter', e => this.handleFilter(e)); // Ecoute de la purge this.addEventListener('purge', () => this.handlePurge()); // Ecoute changement d'un texte de tâche this.addEventListener('update', e => this.handleUpdate(e)); } connectedCallback() { // Chargement HTMML this.innerHTML = html(this.page, tasks.checkStill(), tasks.checkComplete()); // Chargement de la page window.addEventListener('load', () => this.reset()); } // Rafraichissement de la liste reset() { const ul = document.createElement('ul'); Array.from(tasks.getFiltered(this.page)).map(([text, complete]) => ul.appendChild(new TodoLine(text, complete))); this.querySelector('ul').replaceWith(ul); } // Ajout d'une ligne add(text) { // On vérifie que la tâche n'existe pas if(tasks.hasTask(text)) { // Alerte si la tâche existe déjà this.dispatchEvent(new CustomEvent('alert-danger', { bubbles: true })); } else { // On ajoute la tâche dans le dictionnaire tasks.add(text, false); // On ajoute la tâche dans la liste si on n'affiche pas les tâches finies if(this.querySelector('todo-filters').filter !== 'finis') { this.querySelector('ul').appendChild(new TodoLine(text, false)); } else { // Sinon on donne l'info this.dispatchEvent(new CustomEvent('alert-success', { bubbles: true })); } // Footer this.updateFooter(); } } // Mise à jour footer updateFooter() { const footer = this.querySelector('todo-footer'); footer.number = tasks.checkStill(); footer.btn = tasks.checkComplete(); } // Changement du filtre handleFilter(e) { location.href = '/#' + e.target.filter; this.page = e.target.filter; this.reset(); } // Purge des finis handlePurge() { // Purge dans le dictionnaire tasks.clean(); // Rafraichissement de la liste this.reset(); // Footer this.updateFooter(); } // Suppression d'une ligne handleDelete(e) { tasks.del(e.target.text); // Rafraichissement de la liste this.reset(); // Footer this.updateFooter(); } // changement marquage d'une ligne handleToggle(e) { // Changement dans le dictionnaire tasks.update(e.target.text, !e.target.complete); // Rafraichissement de la liste this.reset(); // Footer this.updateFooter(); } // Changement texte d'une ligne handleUpdate(e) { // On vérifie que la tâche n'existe pas if(tasks.hasTask(e.detail.new)) { // Alerte si la tâche existe déjà this.dispatchEvent(new CustomEvent('alert-danger', { bubbles: true })); // Raffraichissement de la ligne e.target.text = e.detail.old; } else { // Sinon on met à jour tasks.changeKey(e.detail.old, e.detail.new); // Raffraichissement de la ligne e.target.text = e.detail.new; } } } if (!customElements.get('todo-list')) { customElements.define('todo-list', TodoList); }
En effet ce composant doit gérer 3 composants enfants ainsi que son modèle. Du coup il écoute pas mal d’événements :
// Ecoute suppression de tâche this.addEventListener('delete', e => this.handleDelete(e)); // Ecoute changement marquage this.addEventListener('toggle', e => this.handleToggle(e)); // Ecoute des filtres this.addEventListener('filter', e => this.handleFilter(e)); // Ecoute de la purge this.addEventListener('purge', () => this.handlePurge()); // Ecoute changement d'un texte de tâche this.addEventListener('update', e => this.handleUpdate(e));
D’autre part il y a un routage rudimentaire pour retrouver l’état en fonction du filtre sélectionné :
this.page = window.location.hash.substr(1); if(this.page == '') this.page = 'tous';
J’ai mis beaucoup de commentaires pour s’y retrouver.
Je ne vais pas analyser tout ce code pour ne pas alourdir cet article.
Le composant todo-alert
Ce composant a pour fonction d’afficher une alerte. On a 3 attributs au niveau de la vue :
export default (type, text, display) => ` <div id="alert" style="display: ${ display };"> <input class="alert-state" id="alert" type="checkbox"> <div class="alert ${ type } dismissible"> ${ text } <label class="btn-close" for="alert">X</label> </div> </div> `;
On peut ainsi régler :
- l’aspect de l’alerte (danger, success…)
- le texte de l’alerte
- l’affichage (cachée ou apparente)
Le Javascript est tout simple :
import html from './view.js'; export default class TodoAlert extends HTMLElement { connectedCallback() { this.innerHTML = html(this.getAttribute('type'), this.getAttribute('text'), this.display); this.querySelector('.btn-close').addEventListener('click', () => { this.display = 'none'; this.reset(); }); } reset() { this.querySelector('#alert').style.display = this.display; } set display(value) { this.setAttribute('display', value); this.reset(); } get display() { return this.getAttribute('display'); } } if (!customElements.get('todo-alert')) { customElements.define('todo-alert', TodoAlert); }
Le composant todo-app
C’est le wrapper de l’application. Au niveau de la vue on a l’incorporation des composants d’alerte, la zone de saisie, la page modale d’aide, l’incorporation du composant de la liste :
export default () => ` <todo-alert id="alert-danger" type="alert-danger" text="Cette tâche existe déjà !" display="none"></todo-alert> <todo-alert id="alert-success" type="alert-success" text="La tâche a bien été ajoutée !" display="none"></todo-alert> <div class="row flex-edges"> <input id="add" type="text" placeholder="Nouvelle tâche"> <label class="paper-btn btn-small btn-secondary" for="aide">Aide</label> </div> <input class="modal-state" id="aide" type="checkbox"> <div class="modal"> <label class="modal-bg" for="aide"></label> <div class="modal-body"> <label class="btn-close" for="aide">X</label> <h4 class="modal-title">Un peu d'aide !</h4> <h5 class="modal-subtitle">Ajouter une tâche</h5> <p class="modal-text">Entrez le texte dans la zone de saisie et utilisez la touche "Entrée" pour valider.</p> <h5 class="modal-subtitle">Modifier une tâche</h5> <p class="modal-text">Pour changer l'état utilisez le bouton "Marquer/Démarquer".</p> <p class="modal-text">Pour changer le texte faites un double clic sur celui-ci et utilisez la zone de saisie qui apparait.</p> <h5 class="modal-subtitle">Supprimer une tâche</h5> <p class="modal-text">Utilisez le bouton rouge "Supprimer".</p> <h5 class="modal-subtitle">Filtrer les tâches</h5> <p class="modal-text">Utilisez les boutons radio en bas de la page.</p> <h5 class="modal-subtitle">Purger les tâches terminées</h5> <p class="modal-text">Utilisez le bouton jaune en bas à droite de la page.</p> </div> </div> <todo-list></todo-list> `;
Au niveau du Javascript c’est aussi assez léger :
import html from './view.js'; import TodoList from './list/index.js'; import TodoAlert from './alert/index.js'; export default class TodoApp extends HTMLElement { constructor() { super(); // Ecoute pour alerte texte déjà existant this.addEventListener('alert-danger', () => { console.log('toto'); this.querySelector('#alert-danger').display = 'block'; }) // Ecoute pour alerte texte bien saisi mais pas affiché this.addEventListener('alert-success', () => this.querySelector('#alert-success').display = 'block'); } connectedCallback() { this.innerHTML = html(); // Entrée pour un ajout de tâche this.querySelector('#add').addEventListener('keydown', e => { if(e.key === 'Enter') { this.addTask(e.target); } }); } addTask(target) { // Si le texte n'est pas vide if(target.value !== '') { this.querySelector('#alert-danger').display = 'none'; this.querySelector('#alert-success').display = 'none'; this.querySelector('todo-list').add(target.value); target.value = ''; } } } if (!customElements.get('todo-app')) { customElements.define('todo-app', TodoApp); }
Essentiellement on gère les nouvelles tâches et les alertes. Il a deux composants enfants : les alertes et la liste des tâches.
On a deux types d’alertes, celle pour une tâche déjà existante :
Et celle qui informe l’utilisateur qu’il a bien entré une tâche quand il est en affichage des tâche terminées :
Conclusion
On a vu dans cet article qu’on peut décomposer une application en plusieurs composants, ce qui permet une meilleure organisation du code et une meilleure lisibilité. Il existe évidemment plusieurs façons de faire et je n’ai pas forcément choisi la plus optimale.
Je ne m’étais pas jusque là attardé sur cette posibilité native d’organisation en composants en privilégiant plutôt des framewoks comme Vue.js. On peut se poser des questions quant à la pertinence relative des différentes solutions.
Une version 4 adaptée à l’utilisation d’une API de cette application figure dans cet article.