AngularJS permet d'étendre le vocabulaire HTML et de créer des composants réutilisables.
Comment rendre une bibliothèque AngularJS accessible? Angular Bootstrap
offre un large panel de composants pouvant être testés suivant la grille WAI-ARIA. Cette bibliothèque est un très
bon support pour illustrer notre exemple. Premièrement vous pouvez créer un module AngularJS qui étendra le module d'origine.
var a11yBootstrapModule = angular.module('a11yBootstrap', ['ui.bootstrap']);
Ici, vous pouvez injecter le module ui.bootstrap dans votre nouveau module. De cette façon, toutes les
modifications dans a11yBootstrap n'interfèrent pas avec le module d'origine. Ce module va être enrichi
par les corrections d'accessibilité.
Dans AngularJS toute manipulation de DOM doit passer par une directive. Voici un exemple de directive,
nommée maDirective. Il est possible d'appeler sa directive avec le même nom que la bibliothèque
Angular Bootstrap sans écraser celle d'origine, ce qui peut être pratique pour les
corrections d'accessibilité. On peut, de plus, jouer sur 2 paramètres pour intervenir dans le DOM avant ou
après la directive d'origine. On peut augmenter le paramètre priority pour intervenir
avant la directive d'origine. Il est aussi possible d'utiliser le service $timeout
pour être sûr d'intervenir en dernier sur DOM, ici avec la fonction after.
C'est dans la directive que nous allons manipuler le DOM et ajouter les gestionnaires d'évènements.
De manière générale, chaque gestionnaire d'événements ajouté au document ou en dehors du composant de la directive sera
retiré à la destruction du DOM. Il faut pour cela attendre l'évènement $destroy.
l’absence de titre du composant, aucun titre ne doit être vocalisé lors de la prise de focus sur le composant ;
l’absence de valeur enrichie, si nécessaire, qui sera verbalisée à la place de la valeur courante du slider et qui peut être composée de la valeur courante et d'un texte en suffixe ;
Corrections
Lors de la définition du composant, il faut ajouter un attribut title="" ou aria-labelledby="[ID]", [ID] faisant référence à un identifiant de la page;
Plusieurs améliorations sont nécessaires pour rendre ce composant accessible :
Le composant doit comporter un attribut aria-labelledby
ou aria-label afin que le titre de la modale soit
vocalisé lors de la prise de focus.
Une fois la modale ouverte, la tabulation doit être restreinte aux éléments
focusables de la modale pour éviter que l'utilisateur n'en sorte.
Lors de la fermeture de la modale, le focus doit être déplacé sur l'élément
qui a déclenché son ouverture, afin que l'utilisateur poursuive
la navigation.
Pour rendre accessible ce composant, nous allons créer une directive
chargé de gérer le focus à l'intérieur d'un élément, et nous allons
modifier le template repris dans la démo.
enforceFocus
Cette directive est chargé de "piéger" le focus dans un composant donné.
Dans la phase link, il faut sauvegarder le focus
courant puis revenir à l'élément lors de la fermeture de la modale. L'évenement
$destroy permet de savoir quand la directive est détruite du
DOM, ce qui correspond ici à la fermeture de la modale.
De plus, à l'ouverture, nous plaçons le focus sur le premier élement avec iElm[0].focus().
a11yBootstrapModule.directive('enforceFocus', ['$document', '$timeout',function($document, $timeout){
return {
link: function($scope, iElm) {
//Save current focus
var modalOpener = $document[0].activeElement;
$timeout(function(){
iElm[0].focus()
});
$scope.$on('$destroy',function() {
//back to first focus
modalOpener.focus();
});
}
};
}])
Toujours dans la directive enforceFocus, il faut forcer
le focus dans la modale. Il suffit d'écouter l'évenement focus sur le document.
Si le focus tombe en dehors, nous le replaçons dans la modale.
Nous ajoutons donc la fonction à l'initialisation avec addEventListener.
Il nous reste à régler le problème de Shift+Tab. Il faut écouter les évenements keydown
sur le premier élément (iElm). Si les touches Shift+Tab sont activés sur le premier élément
nous renvoyons au dernier élément. La fonction lastFocusable détermine le dernier élément focusable.
a11yBootstrapModule.directive('enforceFocus', ['$document', '$timeout',function($document, $timeout){
return {
link: function($scope, iElm) {
var tababbleSelector = 'a[href], area[href], input:not([disabled]),'+
' button:not([disabled]),select:not([disabled]), textarea:not([disabled]),'+
' iframe, object, embed, *[tabindex], *[contenteditable]';
//return lastFocusable element inside modal
function lastFocusable(domEl) {
var list = domEl.querySelectorAll(tababbleSelector);
return list[list.length - 1];
}
var lastEl = lastFocusable(iElm[0]);
//focus lastElement when shitKey Tab on first element
function shiftKeyTabTrap (evt) {
if(iElm[0] === evt.target && evt.shiftKey && evt.keyCode === 9){
lastEl.focus();
evt.preventDefault();
}
}
iElm.bind('keydown', shiftKeyTabTrap);
}
};
}]);
Lors de la destruction de la directive, on détache les gestionnaires d'événements.
Il faut modifier le template d'origine pour ajouter notre directive via l'attribut
enforce-focus.
Dans cette bibliothèque, la modale est ajouter à la fin du body.
Ce qui fait sortir le focus de la fenêtre lors de l'appui successif sur Tab.
Il faut donc ajouter un élément pour piéger le focus avant qu'il sorte du contenu principal du body. Dans l'exemple ci-dessous,
l'ajout de l'élèment à la fin <div tabindex="0"></div> permet de piéger le focus.
Dans le cas ou la progress bar met à jour une zone
de l'interface, il est nécessaire de renseigner plusieurs informations pour
les lier ensemble.
On peut ajouter directement l'attribut aria-describedby HTML ainsi que l'id sur le composant.
Il faut ensuite piloter la valeur de $scope.busy dans le contrôleur de l'application suivant l'état du chargement.
<progressbar animate="false" aria-labelledby="titre-progress" id="progressbar" value="dynamic" type="success">
<b>{{dynamic}}%</b>
</progressbar>
<div id="region" aria-busy="{{busy}}" aria-describedby="progressbar">
<p>Région mise a jour par la progressbar</p>
<button class="btn btn-sm btn-primary" type="button" ng-click="start()">Démarrer</button>
</div>
Contenu statiqueContenu statique avec un lien
{{tab.content}}
Corrections
Plusieurs améliorations sont nécessaires pour rendre ce composant accessible :
La liste d'onglets doit posséder un attribut role="tablist"
Chaque onglet doit posséder un attribut role="tab"
Chaque panneau doit posséder un attribut role="tabpanel"
Chaque onglet actif doit posséder un attribut aria-selected="true"
(ou "false" s'il est inactif) pour préciser son état.
Chaque onglet doit posséder un attribut aria-controls="id-du-panneau"
qui le lie au panneau qu'il contrôle.
Chaque panneau doit posséder un attribut aria-labelledby="id-de-l-onglet"
qui le lie à l'onglet qui le contrôle.
Depuis un onglet, les touches ↑ et ←
doivent permettre d'atteindre l'onglet précédent.
Depuis un onglet, les touches ↓ et →
doivent permettre d'atteindre l'onglet suivant.
Il faut dans un premier temps corriger le template par défaut. On peut facilement ajouter les attributs roletablist et tabpanel sur le template template/tabs/tabset.html.
On peut ensuite ajouter le role tab sur le template/tabs/tab.html.
Pour corriger aria-selected="true" sur le template template/tabs/tab.html
on peut utiliser une expression Angular pour changer la valeur en fonction du $scope active. Ce qui donne
aria-selected="{{active ? 'true' : 'false'}}".
Pour corriger aria-controls="id-du-panneau" et aria-labelledby="id-de-l-onglet",
il faut procéder en 2 étapes. En premier créer un id unique, puis deuxième placer correctement l'id dans le DOM.
Il faut créer une factory qui renvoie un id unique. Cette fonction prend en paramètre un préfixe puis cherche dans le document
un id unique aléatoirement.
a11yBootstrapModule.factory('getUID', function(){
return function(prefix){
do {
prefix += Math.floor(Math.random() * 1000000);
} while (document.getElementById(prefix));
return prefix;
};
});
Nous allons maintenant placer cet id unique dans le DOM. Nous allons donc injecter notre factory getUID
dans la directive. Nous avons besoin d'ajouter les id après le rendu du DOM, il faut pour cela ajouter le service
$timeout. Ainsi la fonction render() sera appliquée après le rendu du DOM.
Dans un premier temps, il faut récupérer le tableau de tabs et de tabpanels,
puis le pacourir avec un forEach.
Pour chaque tab nous devons générer un id unique, y affecter et ajouter l'attribut aria-labelledby
au panel correspondant. De la même manière, pour chaque panel, il faut générer un id unique, il affecter et ajouter l'attribut
aria-controls au tab correspondant.
a11yBootstrapModule.directive('tabpanel', ['getUID', '$timeout',function(getUID, $timeout){
return {
link: function($scope, iElm, iAttrs, controller) {
function render() {
var tablist = iElm[0].firstElementChild;
var tabs = angular.element(tablist).children();
var tabContent = iElm[0].lastElementChild;
var tabpanels = angular.element(tabContent).children();
angular.forEach(angular.element(tabs), function(value, key){
var tab = angular.element(value);
var panel = angular.element(tabpanels[key]);
var idtab = getUID('tab-');
tab.attr('id', idtab);
panel.attr('aria-labelledby', idtab);
var idpanel = getUID('panel-');
panel.attr('id', idpanel);
tab.attr('aria-controls', idpanel);
});
}
$timeout(render,0);
}
};
}]);
Pour la correction de la gestion clavier, on peut reprendre la directive keyboardRotate avec
le paramètre recursion à 1. On donc ajouter keyboard-rotate="1" à notre template.
Plusieurs améliorations sont nécessaires pour rendre ce composant accessible :
Le tooltip doit posséder un attribut role="tooltip"
Le texte doit être lié au tooltip par un attribut aria-describedby
Le tooltip doit pouvoir être caché lors de l'appui sur Echap
Pour corriger et manipuler le DOM, il faut créer une directive du même nom d'origine, tooltipPopup.
Lors de la création de la tooltip, il faut ajouter son role et un identifiant unique.
On va pouvoir ici réutiliser la factory getUID pour générer un id unique.
On peut de cette façon ajouter un id à la tooltip, et l'attribut aria-describedby
à l'élément d'origine.
Lors de la destruction, il faut retirer l'attribut aria-describedby de l'élément d'origine.
a11yBootstrapModule.directive('tooltipPopup', ['getUID',function(getUID){
return {
link: function($scope, iElm) {
//Add role tooltip
iElm.attr('role', 'tooltip');
//Add a Unique ID
var idtooltip = getUID('tooltip-');
iElm.attr('id', idtooltip);
var originElement = angular.element(iElm[0].previousElementSibling);
originElement.attr('aria-describedby', idtooltip);
$scope.$on('$destroy',function() {
originElement.removeAttr('aria-describedby');
});
}
};
}]);
Nous pouvons maintenant compléter la directive en ajoutant un gestionnaire d'évènement pour la touche Echap.
La fonction dismissTooltip va écouter la touche Echap, pour supprimer la
tooltip lors de l'appui. Lors de la destruction, on retire le gestionnaire d'évènements,
originElement.unbind('keyup', dismissTooltip);.
HTML dans l'en-tête
Un peu de contenu pour illustrer les en-têtes HTML.
Corrections
Plusieurs améliorations sont nécessaires pour rendre ce composant accessible :
L'accordéon doit posséder un attribut role="tablist"
afin d'indiquer sa fonction
Chaque titre doit posséder un attribut role="tab"
Chaque titre d'un panneau ouvert doit comporter un attribut
aria-selected="true" (ou "false"
si le panneau est fermé) pour préciser son état.
Chaque titre d'un panneau ouvert doit comporter un attribut
aria-expanded="true" (ou "false"
si le panneau est fermé) pour préciser son état.
Chaque panneau doit posséder un attribut role="tabpanel"
Chaque panneau doit posséder un attribut aria-labelledby="id-du-titre"
pour le lier à son titre.
Chaque panneau ouvert doit posséder un attribut aria-hidden="false"
(ou "true" s'il est fermé) pour préciser son état.
Depuis un titre, les touches ↑ et ←
doivent permettre d'atteindre le titre précédent.
Depuis un titre, les touches ↓ et →
doivent permettre d'atteindre le titre suivant.
Depuis un titre, la touche Espace doit permettre d'ouvrir
ou de fermer le titre correspondant.
On peut ajouter dans le template les attributs maquant pour gérer correctement accessibilité, les rôles et les attributs
aria-selected, aria-expanded, aria-hidden.
Pour corriger la gestion clavier le composant d'origine n'expose pas correctement les bons paramètres. Il faut donc réimplémenter complétement la gestion clavier
en reprenant les directives accordionGroup, accordion et le contrôleur AccordionController puis les ajouter à notre template corrigé.
.controller('GroupController', ['$scope', '$attrs', 'accordionConfig', function ($scope, $attrs, accordionConfig) {
// This array keeps track of the accordion groups
this.groups = [];
this.groupsElem = [];
// This is called from the accordion-group directive to add itself to the accordion
this.addGroup = function(groupScope, element) {
var that = this;
this.groups.push(groupScope);
this.groupsElem.push(element);
groupScope.$on('$destroy', function (event) {
that.removeGroup(groupScope);
});
};
// This is called from the accordion-group directive when to remove itself
this.removeGroup = function(group) {
var index = this.groups.indexOf(group);
if ( index !== -1 ) {
this.groups.splice(index, 1);
this.groupsElem.splice(index, 1);
}
};
this.initFocusable = function() {
var that = this;
angular.forEach(this.groups, function (group, index) {
if ( index === 0 ) {
group.isFocused = true;
that.groups.indexSelected = 0;
}
});
};
this.nextFocusable = function(change) {
this.groups.indexSelected = this.modulo(this.groups.indexSelected + change,this.groups.length);
this.changeSelected();
var focusElement = this.groupsElem[this.groups.indexSelected];
focusElement[0].focus();
};
this.elemFocus = function(group) {
var index = this.groups.indexOf(group);
this.groups.indexSelected = index;
this.changeSelected();
};
this.changeSelected = function() {
var that = this;
angular.forEach(this.groups, function (group, index) {
group.isFocused = false;
if ( index === that.groups.indexSelected ) {
group.isFocused = true;
}
});
};
this.modulo = function(i, iMax) {
return ((i % iMax) + iMax) % iMax;
};
}])
// The group directive simply sets up the directive controller
.directive('group', function () {
return {
restrict:'EA',
controller:'GroupController',
priority: 10000,
link: function(scope, element, attrs, groupCtrl) {
groupCtrl.initFocusable();
}
};
})
// The group-item directive indicates a block of html that will expand and collapse in an accordion
.directive('groupItem', ['$timeout',function($timeout) {
return {
require:'^group',
restrict:'EA',
priority: 10000,
scope: true,
link: function(scope, element, attrs, groupCtrl) {
scope.isFocused = false;
groupCtrl.addGroup(scope, element);
function KeyTrap (evt) {
var keyCode = evt.keyCode;
//Right key and up key
if (keyCode === 39 || keyCode === 40) {
groupCtrl.nextFocusable(1);
scope.$apply();
}
//Left key and down key
if (keyCode === 37 || keyCode === 38) {
groupCtrl.nextFocusable(-1);
scope.$apply();
}
}
element.on('keydown',KeyTrap);
element.on('click', function() {
groupCtrl.elemFocus(scope);
scope.$apply();
});
}
};
}])
Pour corriger la touche espace, on peut créer une directive qui permettra
d'ajouter une fonction sur cette touche spécifique. Il suffit ensuite d'ajouter
notre directive key-space="toggleOpen()" au template template/accordion/accordion-group.html.
Contrairement à la documentation AngularJS, on ne va pas utiliser des éléments <label> pour définir les boutons, car ces éléments ne sont pas accessibles au clavier, mais on va utiliser des éléments <button>.
Pour pouvoir gérer correctement l'interaction au clavier avec les touches ↑, ↓, →, ←, on créé une directive keyboardRotate qu'on applique sur le composant principal (qui a la propriété role="radiogroup"). Cette directive écoute les évènements au clavier mentionnés précédement, et suivant l'action effectuée, simule un clic sur le bouton suivant ou précédent (avec des tests si jamais on interagit sur le dernier bouton de la liste: l'utilisateur sera alors renvoyé sur le premier élément), et lui donne le focus.
Une seconde directive est utilisée, et surcharge la directive existante btnRadio. Celle-ci s'occupe d'attribuer le paramètre aria-checked avec la valeur adéquate, et rend uniquement accessible au clavier l'élément sélectionné. Pour ne pas donner accès au clavier aux éléments non sélectionnés, c'est la propriété tabindex="-1" qui est utilisée.
Pour la directive keyboardRotate, le paramètre recursion n'est pas utilisé dans cet exemple, mais est utilisé pour le composant tabpanel. Il permet lorsqu'il est à 0 de se déplacer dans une liste d'élèments, et lorsqu'il est à 1 dans une liste imbriquée d'éléments. Il permet de naviguer correctmenet au clavier dans cette liste grace aux touches ↑, ↓, →, ←.
a11yBootstrapModule.directive('keyboardRotate',['$document','$timeout',function($document,$timeout){
return {
priority: 200, //Make sure watches are fired after any other directives that affect the ngModel value
restrict: 'A',
scope: {
param : '@keyboardRotate',
},
link: function($scope, iElm, iAttrs, controller) {
var recursion = $scope.param;
$timeout(function(){
function KeyTrap (evt) {
var next;
var keyCode = evt.keyCode;
//Right key and up key
if (keyCode === 39 || keyCode === 40) {
next = evt.target.nextElementSibling;
if (recursion === '1') {
next = evt.target.parentElement.nextElementSibling;
}
//if last go to first
if (!next) {
next = iElm.children()[0];
}
}
//Left key and down key
if (keyCode === 37 || keyCode === 38) {
next = evt.target.previousElementSibling;
if (recursion === '1') {
next = evt.target.parentElement.previousElementSibling;
}
//if first go to last
if (!next) {
var child = iElm.children();
next = child[child.length-1];
}
}
//go to next element if defined (previous or next)
if (next) {
if (recursion === '1') {
next = next.children[0];
}
next.click();
next.focus();
}
}
angular.element(iElm[0]).on('keydown',KeyTrap);
},0);
}
};
}]).directive('btnRadio', [function(btnRadioProvider){
return {
require: ['btnRadio', 'ngModel'],
priority: 200, //Make sure watches are fired after any other directives that affect the ngModel value
link: function($scope, iElm, iAttrs, controller) {
var buttonsCtrl = controller[0], ngModelCtrl = controller[1];
//model -> UI
ngModelCtrl.$render = function () {
var check = angular.equals(ngModelCtrl.$modelValue, $scope.$eval(iAttrs.btnRadio));
iElm.attr('aria-checked', check);
iElm.attr('tabindex', '-1');
if (check) {
iElm.attr('tabindex', '0');
}
iElm.toggleClass(buttonsCtrl.activeClass, angular.equals(ngModelCtrl.$modelValue, $scope.$eval(iAttrs.btnRadio)));
};
}
};
Checkbox
Démonstration
Une simple checkbox
{{singleModel}}
Un groupe de Checkbox
{{checkModel}}
Corrections
Plusieurs améliorations sont nécessaires pour rendre ce composant accessible :
La checkbox doit posséder un attribut role="checkbox".
La checkbox doit posséder un attribut aria-checked suivant l'état en cours.
L'élément structurant le groupe possède un role="group".
L'élément structurant possède une propriété aria-labelledby="[ID_titre]" référençant le titre role="group".
La touche espace permet de cocher/décocher la checkbox.
Il faut dans un premier temps ajouter la sémantique. Ce qui peut facilement être ajouter dans la partie HTML.
On ajoute donc les attributs role et aria-labelledby en reprenant le titre.
Il faut maintenant ajouter l'attribut aria-checked suivant l'état en cours.
Malheuresement, la bibliothèque AngularUi ne partage pas cette valeur. On va donc réimplémenter une partie de la bibliothèque
Pour changer cet attribut. Notre directive doit avoir une priorité plus élevé, pour surcharger la contrôleur d'origine.
a11yBootstrapModule.directive('btnCheckbox', [function(btnRadioProvider){
return {
require: ['btnCheckbox', 'ngModel'],
priority: 200, //Make sure watches are fired after any other directives that affect the ngModel value
link: function($scope, iElm, iAttrs, controller) {
var buttonsCtrl = controller[0], ngModelCtrl = controller[1];
function getTrueValue() {
return getCheckboxValue(iAttrs.btnCheckboxTrue, true);
}
function getCheckboxValue(attributeValue, defaultValue) {
var val = $scope.$eval(attributeValue);
return angular.isDefined(val) ? val : defaultValue;
}
//model -> UI
ngModelCtrl.$render = function () {
var check = angular.equals(ngModelCtrl.$modelValue, getTrueValue());
iElm.attr('aria-checked', check);
iElm.toggleClass(buttonsCtrl.activeClass, check);
};
}
};
}]);
Datepicker
Démonstration
La date sélectionnée est : {{dt | date:'fullDate' }}
Popup
Pour ce composant, les erreurs relevées sont :
Aucun titre de jour ne possède un attribut role="columnheader"
Aucune ligne qui contient les numéros de jours ne possède un attribut role="row”
Pour chaque jour, il ne possède pas d'attribut aria-selected suivant la selection en cours.
Lors de l'utilisation de la touche Echap, le focus n'est pas redonné à l'élément ayant ouvert le calendrier.
Corrections
Gestion des attributs dans le tableau des jours
On va surcharger le template de rendu du tableau des jours template/datepicker/day.html, et ajouter les attributs manquants role="row” sur les numéros de jours, et role="columnheader" sur les titres de jours.
La surchage de ce template permet également de gérer des attributs dynamiques aria-selected et leur valeur true si le jour est sélectionné ou false si le jour n'est pas sélectionné.
Pour corriger la gestion clavier, il faut créer un directive qui enregistrera l'élèment actif à l'ouverture.
Puis on peut ajouter un gestionnaire d'évèment qui lorsque la touche Echap sera appuyé renverra le focus sur l'élèment précédant.
Les corrections de sémantique peuvent être résolues assez simplement en modifiant les templates,
si la bibliothèque d'origine expose correctement les données. La gestion au clavier peut se révéler
plus compliqué à gérer, mais ce n'est néanmoins pas insurmontable dans l'exemple de cette bibliothèque.
Certaines corrections ont parfois une approche assez détournées de la philosophie d'AnugarJs et le mieux reste une correction complète dans la bibliothèque d'origine.
Si la librairie de composant a encore de nombreuse erreurs; le framework AngularJs a fait un réel effort pour l'accessibilité
à partir de la version 1.3 avec l'apparition de ngAria.
Malheuresement à l'heure des tests, la librairie de composant AngularJs Bootstrap n'a pas encore migré avec cette nouvelle version.