Comment transformer un composant HTML/JS existant et accessible en version Angular ?

Introduction

Avec l’apparition des frameworks JS tels que Angular, React, VueJS, l’usage des paquets Node.js a explosé. Et s’il est facile d’en ajouter via NPM (le gestionnaire de paquets par défaut de l’écosystème Node.js) dans nos projets pour éviter de coder des composants soi-même, ils font très souvent face au problème des composants tout faits et faciles à installer, à savoir qu’ils ne sont pas forcément accessibles à tous et toutes.

Un beau jour, dans un projet où on avait l’habitude d’installer des composants, je me suis rendu compte qu’ils avaient tous un défaut d’accessibilité. Soit un composant n’était pas accessible et je devais le modifier pour qu’il le soit, soit il l’était mais n’était pas personnalisable. Jamais les deux en même temps et je ne trouvais pas mon bonheur.

En tombant sur les exemples d’ARIA practices du W3C, j’ai vu qu’on mettait à disposition du code en HTML, CSS et JavaScript pour expliquer comment créer une liste déroulante, un carrousel, un sélecteur de date, etc. de manière accessible. En voyant ces exemples, j’ai eu un déclic et j’ai réalisé que je pouvais tout à fait adapter leur code en version Angular, framework que j’utilise dans mon travail.

En effet, je suis développeuse, et de ce fait, je peux coder un composant moi-même. Certes, on avait l’habitude d’installer des paquets pour gagner du temps, mais je perdais un temps fou à essayer de les adapter comme je voulais. En créant moi-même les composants, non seulement je gagne du temps mais je l’adapte aussi selon mes besoins. Et grâce aux exemples du W3C, j’étais en mesure de le faire.

Comment je m’y suis prise ? C’est l’objet de cet article.

Pour cet article, je vais prendre pour exemple une liste déroulante comportant une liste de fruits. Pour la partie technique, j’utiliserai du HTML, du SCSS et TypeScript sur Angular.

Liste déroulante comportant une liste de fruits

Étape 1 – Analyser le code

La première étape est de lire le code source mise à disposition sur le Github du W3C ou via le CodePen. Il faut l’analyser, le comprendre et surtout se l’approprier en faisant plein de tests avec le clavier et le lecteur d’écran. J’analyse les interactions HTML et JavaScript en temps réel grâce aux outils de développement du navigateur auxquels on accède avec la touche F12, les informations vocalisées par le lecteur d’écran et le fonctionnement du composant avec mon clavier.

Code source en HTML de la liste déroulante accessible avec des liens pointant vers le code source CSS et JavaScript sur le site du W3C

Il faut également lire la documentation en anglais mise à disposition sur la même page de l’exemple.

C’est une étape importante pour comprendre les atouts et les défauts du composant afin de l’adapter comme on le souhaite.

Étape 2 – Adapter le composant en HTML

Voici le code HTML de l’exemple proposé par le W3C.

<label id="combo1-label" class="combo-label">Favorite Fruit </label>
 <div class="combo js-select">
    <div id="combo1" class="combo-input" role="combobox">
    </div>
    <div class="combo-menu" role="listbox" id="listbox1">
        <!-- options are inserted here -->
        <!-- e.g. <div role="option" id="op1">option text</div> -->
    </div>
</div>

En ouvrant la liste déroulante et en la parcourant avec les flèches de mon clavier, je vois le DOM changer grâce à l’inspecteur de code. Ce que vous voyez dans le premier onglet est la version HTML avec le DOM modifié. En voyant ce DOM, j’ai pu faire la modification du HTML en Angular.
L’avantage d’Angular, c’est que je peux créer des objets et les intégrer directement dans le HTML.

Onglet HTML
<label id="combo1-label" class="combo-label">Favorite Fruit</label>
<div class="combo js-select open">
  <div
    aria-controls="listbox1"
    aria-expanded="false"
    aria-haspopup="listbox"
    aria-labelledby="combo1-label"
    id="combo1"
    class="combo-input"
    role="combobox"
    tabindex="0"
    aria-activedescendant="combo1-4"
  >
    Boysenberry
  </div>
  <div
    class="combo-menu"
    role="listbox"
    id="listbox1"
    aria-labelledby="combo1-label"
    tabindex="-1"
  >
    <!-- options are inserted here -->
    <!-- e.g. <div role="option" id="op1">option text</div>  -->
    <div role="option" id="combo1-0" class="combo-option" aria-selected="false">
      Choose a Fruit
    </div>
    <div role="option" id="combo1-1" class="combo-option" aria-selected="false">
      Apple
    </div>
    <div role="option" id="combo1-2" class="combo-option" aria-selected="false">
      Banana
    </div>
    <div role="option" id="combo1-3" class="combo-option" aria-selected="false">
      Blueberry
    </div>
    <div role="option" id="combo1-4" class="combo-option option-current" aria-selected="true">
      Boysenberry
    </div>
    <div role="option" id="combo1-5" class="combo-option" aria-selected="false">
      Cherry
    </div>
    <div role="option" id="combo1-6" class="combo-option" aria-selected="false">
      Cranberry
    </div>
    <div role="option" id="combo1-7" class="combo-option" aria-selected="false">
      Durian
    </div>
    <div role="option" id="combo1-8" class="combo-option" aria-selected="false">
      Eggplant
    </div>
    <div role="option" id="combo1-9" class="combo-option" aria-selected="false">
      Fig
    </div>
    <div role="option" id="combo1-10" class="combo-option" aria-selected="false">
      Grape
    </div>
    <div role="option" id="combo1-11" class="combo-option" aria-selected="false">
      Guava
    </div>
    <div role="option" id="combo1-12" class="combo-option" aria-selected="false">
      Huckleberry
    </div>
  </div>
</div>
<label id="combo1-label" class="combo-label">Favorite Fruit</label>
<div class="combo" [ngClass]="{ open: isComboboxOpen }">
  <div
    aria-controls="listbox1"
    [attr.aria-expanded]="isComboboxOpen"
    aria-haspopup="listbox"
    aria-labelledby="combo1-label"
    id="combo1"
    class="combo-input"
    role="combobox"
    tabindex="0"
    (click)="isComboboxOpen = !isComboboxOpen"
    (blur)="onComboboxBlur($event)"
    (keydown)="onComboboxKeyDown($event)"
    [attr.aria-activedescendant]="isComboboxOpen ? 'listbox-' + activeID : ''"
    #comboEl>
    {{ selectedOption.name }}
  </div>
  <div
    class="combo-menu"
    role="listbox"
    id="listbox1"
    aria-labelledby="combo1-label"
    tabindex="-1"
    #listboxEl>
    <div
      role="option"
      class="combo-option"
      [ngClass]="{ 'option-current': option.active }"
      *ngFor="let option of options; let index = index"
      [id]="'listbox-' + option.id"
      (click)="onOptionClick(option.id)"    
      [attr.aria-selected]="option.id === selectedOption?.id"
      [attr.aria-setsize]="options.length"
      [attr.aria-posinset]="index + 1">
      {{ option.name }}
    </div>
  </div>
</div>

Comme vous pouvez le constater, j’ai créé plusieurs objets :

  • la variable booléenne isComboboxOpen pour pouvoir rajouter la classe open quand on ouvre la liste déroulante,
  • la variable activeID permettant d’avoir l’identifiant de l’option en cours de sélection de la liste déroulante,
  • la variable options qui est une liste d’options issue de la classe Option comportant trois champs : ID, name et active,
  • la variable selectedOption qui est l’option sélectionnée par l’utilisateur.

Grâce à une boucle for, je crée l’option pour chaque élément de ma liste options et j’y attache les attributs ARIA et les classes nécessaires.

J’ai également associé trois évènements TypeScript à l’élément, qui me permettent de naviguer dans la liste déroulante avec la souris (évènement click) ou le clavier (évènement keydown). Quant à l’événement blur, il me sert à détecter le focus. En effet, si je clique ailleurs, je dois pouvoir fermer la liste.

Je décide d’afficher ou non une classe grâce à l’attribut ngClass qui est associé à la valeur en temps réel. Si l’option est active, alors elle aura la classe option-current. C’est géré nativement plutôt que de faire l’ajout et suppression de la classe côté JavaScript :

// update active option styles
  const options = this.el.querySelectorAll('[role=option]');
  [...options].forEach((optionEl) => {
    optionEl.classList.remove('option-current');
  });
  options[index].classList.add('option-current');

Avec ces ajouts, vous pouvez constater que la base HTML reste la même.

Étape 3 – Animer le composant en TypeScript

Si votre framework ne supporte que du JavaScript, vous pouvez tout à fait réutiliser le script mis à disposition. Dans mon cas, j’ai choisi d’utiliser du TypeScript, un langage orienté objet.

Comment passer de JavaScript à TypeScript ?

L’écriture du TypeScript est un peu différente de celle du JavaScript, mais la base est la même puisque le TypeScript, c’est du JavaScript typé. Je n’avais jamais été à l’aise avec JavaScript mais depuis que je maîtrise le TypeScript, j’ai pu mieux le comprendre et il m’a semblé plus lisible qu’avant, facilitant mon appropriation du code.

Il est important de lire la documentation originelle et de comprendre le code proposé pour pouvoir le reprendre si besoin. Il y a certaines méthodes que j’ai eu besoin d’adapter.

Par exemple, au chargement du document, le script JavaScript va permettre de créer les éléments et d’associer les événements à la liste déroulante. Cette partie a déjà été faite dans ma version HTML, donc je n’ai pas eu besoin de la reprendre.

Le point commun entre les 2 propositions, c’est qu’on initialise la liste et on prend, par défaut, la première option de la liste.

Onglet JavaScript / TypeScript n°1
select.prototype.init = function () {
  // select first option by default
  this.comboEl.innerHTML = this.options[0];

  // add event listeners
  this.comboEl.addEventListener('blur', this.onComboBlur.bind(this));
  this.comboEl.addEventListener('click', this.onComboClick.bind(this));
  this.comboEl.addEventListener('keydown', this.onComboKeyDown.bind(this));

  // create options
  this.options.map((option, index) => {
    const optionEl = this.createOption(option, index);
    this.listboxEl.appendChild(optionEl);
  });
};

// init select
window.addEventListener('load', function () {
  const options = [
    'Choose a Fruit',
    'Apple',
    'Banana',
    'Blueberry',
    'Boysenberry',
    'Cherry',
    'Cranberry',
    'Durian',
    'Eggplant',
    'Fig',
    'Grape',
    'Guava',
    'Huckleberry',
  ];
  const selectEls = document.querySelectorAll('.js-select');

  selectEls.forEach((el) => {
    new Select(el, options);
  });
});
public ngOnInit(): void {
    this.options = [
      { id: 0, name: 'Choose a Fruit', active: true},
      { id: 1, name: 'Apple', active: false },
      { id: 2, name: 'Banana', active: false },
      { id: 3, name: 'Blueberry', active: false },
      { id: 4, name: 'Boysenberry', active: false },
      { id: 5, name: 'Cherry', active: false },
      { id: 6, name: 'Cranberry', active: false },
      { id: 7, name: 'Durian', active: false },
      { id: 8, name: 'Eggplant', active: false },
      { id: 9, name: 'Fig', active: false },
      { id: 10, name: 'Grape', active: false },
      { id: 11, name: 'Guava', active: false },
      { id: 12, name: 'Huckleberry', active: false },
    ];

    this.selectedOption = this.options[0];
  }

J’ai repris beaucoup de fonctions existantes. Certaines ont été adaptées, d’autres ont été reprises telles quelles et d’autres ont été supprimées.

Par exemple, j’ai supprimé la méthode updateMenuState permettant de mettre à jour les états des propriétés. Je n’ai pas besoin de l’utiliser car sur Angular, je gère les propriétés nativement.

Je n’ai pas changé d’un iota le contenu de la méthode getActionFromKey qui me permet de définir les actions en fonction de la touche du clavier saisie.

J’ai renommé certaines méthodes qui sont plus adaptées à mes besoins mais dont le contenu est presque le même.

Onglet JavaScript / TypeScript n°2
Select.prototype.onComboKeyDown = function (event) {
  const { key } = event;
  const max = this.options.length - 1;

  const action = getActionFromKey(event, this.open);

  switch (action) {
    case SelectActions.Last:
    case SelectActions.First:
      this.updateMenuState(true);
    // intentional fallthrough
    case SelectActions.Next:
    case SelectActions.Previous:
    case SelectActions.PageUp:
    case SelectActions.PageDown:
      event.preventDefault();
      return this.onOptionChange(
        getUpdatedIndex(this.activeIndex, max, action)
      );
    case SelectActions.CloseSelect:
      event.preventDefault();
      this.selectOption(this.activeIndex);
    // intentional fallthrough
    case SelectActions.Close:
      event.preventDefault();
      return this.updateMenuState(false);
    case SelectActions.Type:
      return this.onComboType(key);
    case SelectActions.Open:
      event.preventDefault();
      return this.updateMenuState(true);
  }
};

Select.prototype.updateMenuState = function (open, callFocus = true) {
  if (this.open === open) {
    return;
  }

  // update state
  this.open = open;

  // update aria-expanded and styles
  this.comboEl.setAttribute('aria-expanded', `${open}`);
  open ? this.el.classList.add('open') : this.el.classList.remove('open');

  // update activedescendant
  const activeID = open ? `${this.idBase}-${this.activeIndex}` : '';
  this.comboEl.setAttribute('aria-activedescendant', activeID);

  if (activeID === '' && !isElementInView(this.comboEl)) {
    this.comboEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }

  // move focus back to the combobox, if needed
  callFocus && this.comboEl.focus();
};
public onComboboxKeyDown(event: KeyboardEvent): void {

    const action: SelectActionsEnum = this.getActionFromKey(event);

    if (action == null) {
      return;
    }

    event.preventDefault();

    switch (action) {      
      case SelectActionsEnum.First:
      case SelectActionsEnum.Last:
      case SelectActionsEnum.Next:
      case SelectActionsEnum.Previous:
      case SelectActionsEnum.PageUp:
      case SelectActionsEnum.PageDown:
        this.setCurrentOption(this.getUpdatedIndex(action));
        break;
      case SelectActionsEnum.Type:
        this.searchOptionByLetterKey(event.key);
        return;
      case SelectActionsEnum.Open:
        this.isComboboxOpen = true;
        return;
      case SelectActionsEnum.Close:
        this.isComboboxOpen = false;
        return;
      case SelectActionsEnum.CloseSelect:
        this.updateOption(this.activeID, true);
        return;
    }
  }

Je ne vais pas lister toutes les méthodes. Le fichier est un peu long. ;-)

C’est à vous de voir ce qui est nécessaire. Lisez bien le code existant et vous pouvez le moduler à vos besoins en évitant les régressions d’accessibilité.

Étape 4 – Styler le composant en CSS ou SCSS

Après avoir créé le composant et codé les interactions en TypeScript, il faut maintenant le styler. Je n’ai pratiquement rien modifié dans mon CSS si ce n’est que je l’ai converti en SCSS. Le visuel est exactement le même que l’exemple.

Si vous stylez vous-même, gardez en tête que, pour l’accessibilité, on doit différencier l’action de la souris (hover) et l’action du clavier (focus). On doit également respecter les contrastes.

Le composant est prêt. Je peux styler la liste déroulante correspondant à la maquette demandée par mon client et je peux utiliser la même base HTML et TypeScript autant de fois que je le souhaite.

Mais, pour éviter de réécrire à chaque fois le même code, je vais passer à l’étape suivante : rendre le composant réutilisable.

Étape 5 – Rendre le composant réutilisable

Il y a deux listes déroulantes : une liste contenant des fruits et une liste contenant des pays. La première est fermée et le fruit favori choisi est le mûrier-framboisier. La deuxième est ouverte et affiche la liste des pays pointée sur la France. Le design des 2 listes est le même.

Comme vous le constatez sur les exemples suivants, j’ai nommé mon composant combobox-select-only et je l’utilise deux fois. Un pour les fruits et un pour les pays.

En entrée, je donne 4 valeurs :

  • label : le libellé de mon champ,
  • id : l’étiquette de mon champ (il ne faut pas avoir plusieurs listes ayant le même id),
  • options : une liste d’options que je peux modifier à volonté,
  • ngModel : c’est une donnée en mode « two-way ». Quand je sélectionne la valeur d’une liste, elle va s’appliquer dans fruitID ou countryID selon la liste utilisée.

Si la valeur change, je fais appel à la méthode onFruitChange ou onCountryChange pour afficher le nom de l’option sélectionnée.

<combobox-select-only
  label="Favorite Fruit"
  id="fruit"
  [options]="fruits"
  [(ngModel)]="fruitID"
  (ngModelChange)="onFruitChange()"
></combobox-select-only>

<p>Fruit : {{ fruitID }} - {{ selectedFruit?.name }}</p>

<combobox-select-only
  label="Favorite Country"
  id="country"
  [options]="countries"
  [(ngModel)]="countryID"
  (ngModelChange)="onCountryChange()"
></combobox-select-only>

<p>Country : {{ countryID }} - {{ selectedCountry?.name }}</p>
export class AppComponent implements OnInit {
  public fruits: Option[] = [];
  public countries: Option[] = [];

  public fruitID: number;
  public countryID: number;

  public selectedFruit: Option;
  public selectedCountry: Option;

  constructor() {}

  //#region LIFE CYCLES

  public ngOnInit(): void {
    this.fruits = [
      { id: 0, name: 'Choose a Fruit', active: true },
      { id: 1, name: 'Apple', active: false },
      { id: 2, name: 'Banana', active: false },
      { id: 3, name: 'Blueberry', active: false },
      { id: 4, name: 'Boysenberry', active: false },
      { id: 5, name: 'Cherry', active: false },
      { id: 6, name: 'Cranberry', active: false },
      { id: 7, name: 'Durian', active: false },
      { id: 8, name: 'Eggplant', active: false },
      { id: 9, name: 'Fig', active: false },
      { id: 10, name: 'Grape', active: false },
      { id: 11, name: 'Guava', active: false },
      { id: 12, name: 'Huckleberry', active: false },
    ];

    this.countries = [
      { id: 0, name: 'Choose a Country', active: true },
      { id: 1, name: 'Australia', active: false },
      { id: 2, name: 'Belgium', active: false },
      { id: 3, name: 'Brazil', active: false },
      { id: 4, name: 'Canada', active: false },
      { id: 5, name: 'Denmark', active: false },
      { id: 6, name: 'Finland', active: false },
      { id: 7, name: 'France', active: false },
      { id: 8, name: 'Germany', active: false },
      { id: 9, name: 'Japan', active: false },
      { id: 10, name: 'Luxembourg', active: false },
      { id: 11, name: 'New Zealand', active: false },
      { id: 12, name: 'Spain', active: false },
      { id: 13, name: 'Tunisia', active: false },
      { id: 14, name: 'United Kingdom', active: false },
      { id: 15, name: 'United States', active: false },
    ];
  }

  //#endregion

  //#region EVENTS

  public onFruitChange(): void {
    this.selectedFruit = this.fruits.find((x) => x.id == this.fruitID);
  }

  public onCountryChange(): void {
    this.selectedCountry = this.countries.find((x) => x.id == this.countryID);
  }

  //#endregion
}

Étape 6 – Tester

N’hésitez pas à tester votre nouveau composant tout en testant le composant originel du W3C afin de vérifier que leurs comportements sont les mêmes.

Testez-les avec la souris, le clavier et un lecteur d’écran.

L’image suivante est une capture d’écran du lecteur d’écran NVDA que j’utilise pour mes tests d’accessibilité. Étant sourde, j’utilise la visionneuse de paroles pour comprendre ce qui est vocalisé.

Retranscription de la visionneuse de parole : Favorite Fruit liste déroulante Fig réduit Favorite Country liste déroulante France réduit France 8 sur 16 Germany non sélectionné 9 sur 16 Japan non sélectionné 10 sur 16 Luxembourg non sélectionné 11 sur 16 New Zeland non sélectionné 12 sur 16 a Australia non sélectionné 2 sur 16 b Belgium non sélectionné 3 sur 16 r Brazil non sélectionné 4 sur 16 a Tunisia non sélectionné 14 sur 16 Favorite Country liste déroulante Tunisia réduit

Comme vous pouvez le constater, avec le lecteur d’écran NVDA et les touches du clavier, j’ai parcouru la liste :

  1. J’ai ouvert la liste avec la touche Entrée.
  2. Avec la flèche bas, je parcours la liste vers le bas.
  3. J’ai saisi « a », on me propose l’Australie.
  4. « b » pour Belgique.
  5. Suivi de « r » qui me proposera Brésil.
  6. Avec la touche Alt + flèche bas, je suis allée tout en bas de la liste me proposant Tunisie.
  7. En entrant de nouveau la touche Entrée, je sélectionne Tunisie et ça ferme la liste.

Donc n’hésitez pas à vous amuser pour vérifier que tout fonctionne.

Conclusion

Depuis que j’ai fait cette expérience, je n’ai plus hésité à créer moi-même de nouveaux composants à partir des exemples du W3C. Je n’ai plus besoin d’installer des paquets NPM et je réutilise à volonté les composants accessibles que j’ai créés.

Le fait d’avoir fait cette expérience m’a totalement libérée des composants NPM que je trouvais fastidieux à corriger et à moduler selon mes besoins.

Vous pouvez retrouver ma proposition sur Stackblitz.

J’espère que cet article vous donnera envie de créer vos propres composants. Certes, cela vous prendra un peu de temps, mais pas autant que d’installer un composant lambda que vous devrez adapter.

Laisser un commentaire

Les commentaires sont modérés manuellement. Merci de respecter : la personne qui a écrit l'article, les autres participant(e)s à la discussion, et la langue française. Vous pouvez suivre les réponses par flux RSS.