Updated : Fév 04, 2020 in Javascript

Maîtriser Javascript : web components (partie 3)

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.

 

Laisser un commentaire