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.
É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.
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.
<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 classeopen
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.
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.
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
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 dansfruitID
oucountryID
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é.
Comme vous pouvez le constater, avec le lecteur d’écran NVDA et les touches du clavier, j’ai parcouru la liste :
- J’ai ouvert la liste avec la touche Entrée.
- Avec la flèche bas, je parcours la liste vers le bas.
- J’ai saisi « a », on me propose l’Australie.
- « b » pour Belgique.
- Suivi de « r » qui me proposera Brésil.
- Avec la touche Alt + flèche bas, je suis allée tout en bas de la liste me proposant Tunisie.
- 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.
1 commentaires sur cet article
Hasina, le dimanche 12 mars 2023 à 11:15
Bonjour. Merci pour cet article..c’est ce qui m’est arrivé aussi quand je débutais sur Angular..je passais un temps fou à adapter une template de manière à ce qu’elle convient pour mon projet, alors que j’aurai pu créer moi même les composants..