Angular-ui Tutoriel composants d’interface JavaScript

Angular UI Bootstrap V0.12.1 + AngularJs v1.2.28

Méthodologie

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.

					
a11yBootstrapModule.directive('maDirective', ['$timeout', function($timeout){
  return {
    // priority: 10,
    link: function($scope, iElm, iAttrs, controller) {
      function after() {
        //Code exécuté après
      }
      $timeout(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.

					
a11yBootstrapModule.directive('maDirective', ['$document', function($document){
  return {
    // priority: 10,
    link: function($scope, iElm, iAttrs, controller) {

      function escapeKeyBind () {
      }

      //Add escapeKeyBind
      $document.bind('keydown', escapeKeyBind);

      //on $destroy event
      $scope.$on('$destroy', function() {
        //remove escapeKeyBind
        $document.unbind('keydown', escapeKeyBind);
      });


    }
  };
}]);
					
				

Une partie des corrections se fera directement dans les templates.

Démonstration

Exemple de composant AngularJS

{{percent}}%
Valeur: {{rate}} - Lecture seule: {{isReadonly}} - Valeur au survol: {{overStar || "none"}}

Pour ce composant, les erreurs relevées sont :

  • 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;
<h4 id="titre-slider">Exemple de composant AngularJS</h4>
<rating aria-labelledby="titre-slider" ng-model="rate" max="max" readonly="isReadonly" on-hover="hoveringOver(value)" on-leave="overStar = null"></rating>

Démonstration

Selection from a modal: {{ selected }}

Corrections

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.

						
a11yBootstrapModule.directive('enforceFocus', ['$document', '$timeout',function($document, $timeout){
  return {
    link: function($scope, iElm) {

      //enforceFocus inside modal
      function enforceFocus(evt) {
        if (iElm[0] !== evt.target && !iElm[0].contains(evt.target)) {
          iElm[0].focus();
        }
      }
      $document[0].addEventListener('focus', enforceFocus, true);

    }
  };
}]);
						
					

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.

						
a11yBootstrapModule.directive('enforceFocus', ['$document', '$timeout',function($document, $timeout){
  return {
    link: function($scope, iElm) {

      $scope.$on('$destroy',function() {
        //Remove event listener
        $document[0].removeEventListener('focus', enforceFocus, true);
      });
    }
  };
}]);
						
					

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.

						
angular.module("template/modal/window.html", []).run(["$templateCache", function($templateCache) {
  $templateCache.put("template/modal/window.html",
    "<div><div enforce-focus tabindex=\"-1\" role=\"dialog\" class=\"modal fade\" ng-class=\"{in: animate}\" ng-style=\"{'z-index': 1050 + index*10, display: 'block'}\" ng-click=\"close($event)\">\n" +
    "    <div class=\"modal-dialog\" ng-class=\"{'modal-sm': size == 'sm', 'modal-lg': size == 'lg'}\"><div class=\"modal-content\" modal-transclude></div></div>\n" +
    "</div><div tabindex=\"0\"></div></div>");
}]);
						
					

Sémantique de la modale

Il manque toujours l'attribut aria-labelledby, qui peut facilement être ajouté dans le template pour faire référence au titre de notre modal.

						
angular.module("template/modal/window.html", []).run(["$templateCache", function($templateCache) {
  $templateCache.put("template/modal/window.html",
    "<div><div enforce-focus tabindex=\"-1\" aria-labelledby=\"titre-modal\" role=\"dialog\" class=\"modal fade\" ng-class=\"{in: animate}\" ng-style=\"{'z-index': 1050 + index*10, display: 'block'}\" ng-click=\"close($event)\">\n" +
    "    <div class=\"modal-dialog\" ng-class=\"{'modal-sm': size == 'sm', 'modal-lg': size == 'lg'}\"><div class=\"modal-content\" modal-transclude></div></div>\n" +
    "</div><div tabindex=\"0\"></div></div>");
}]);
						
					
						
<script type="text/ng-template" id="myModalContent.html">
	<div class="modal-header">
		<h3 id="titre-modal" class="modal-title">Une modale</h3>
	</div>
	<div class="modal-body">
		<div ng-repeat="item in items">
			<button type="button" class="btn btn-default" ng-click="selected.item = item">{{ item }}</button>
		</div>
		Sélection: <b>{{ selected.item }}</b>
	</div>
	<div class="modal-footer">
		<button type="button" class="btn btn-primary" ng-click="ok()">OK</button>
		<button type="button" class="btn btn-warning" ng-click="cancel()">Annuler</button>
	</div>
</script>
						
					

Démonstration

{{dynamic}}%

Région mise a jour par la progressbar

Corrections

Plusieurs améliorations sont nécessaires pour rendre ce composant accessible :

  • La progress bar doit posséder un attribut title ou aria-labelledby faisant office de nom

Si la progress bar met à jour une zone de l'interface :

  • Cette zone doit comporter un attribut aria-describedby="id-de-la-progress-bar" qui la lie à la progress bar.
  • Lorsqu'une mise à jour est en cours, la zone doit comporter un attribut aria-busy="true", indiquant que son contenu est en cours de mise à jour.

On peut ajouter directement l'attribut aria-labelledby sur le composant AngularJS.

						
<h3 id="titre-progress">Dynamic</h3>
<progressbar animate="false" aria-labelledby="titre-progress" value="dynamic" type="success">
	<b>{{dynamic}}%</b>
</progressbar>
						
					

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>
						
					
						
//Exemple
app.controller('ProgressDemoCtrl', ['$scope', '$timeout',function ($scope, $timeout) {
	$scope.busy = false;
	$scope.dynamic = 0;

	$scope.start = function() {
		progress();
	};

	function progress() {

		$scope.busy = true;

		if ( $scope.dynamic < 100 ) {
			$scope.dynamic++;
			$timeout( progress, 50 );
		}else{
			$scope.busy = false;
		}
	}
}]);
						
					

Démonstration

Contenu statique Contenu 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 role tablist 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'}}".

							
<script type="text/ng-template" id="template/tabs/tabset.html">
	<div>
		<ul role="tablist" class="nav nav-{{type || 'tabs'}}" ng-class="{'nav-stacked': vertical, 'nav-justified': justified}" ng-transclude></ul>
		<div class="tab-content">
			<div role="tabpanel" class="tab-pane"
			ng-repeat="tab in tabs"
			tabindex="0"
			ng-class="{active: tab.active}"
			tab-content-transclude="tab">
			</div>
		</div>
	</div>
</script>


<script type="text/ng-template" id="template/tabs/tab.html">
	<li role="tab" ng-class="{active: active, disabled: disabled}" aria-selected="{{active ? 'true' : 'false'}}">
		<a tabindex="{{active ? '0' : '-1'}}" href ng-click="select()" tab-heading-transclude>{{heading}}</a>
	</li>
</script>
							
						

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.

							
a11yBootstrapModule.directive('tabpanel', ['getUID', '$timeout',function(getUID, $timeout){
  return {
    link: function($scope, iElm, iAttrs, controller) {


      function render() {}

      $timeout(render,0);

    }
  };
}]);
							
						

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.

							
<script type="text/ng-template" id="template/tabs/tabset.html">
	<div>
		<ul role="tablist" keyboard-rotate="1" class="nav nav-{{type || 'tabs'}}" ng-class="{'nav-stacked': vertical, 'nav-justified': justified}" ng-transclude></ul>
		<div class="tab-content">
			<div role="tabpanel" class="tab-pane"
			ng-repeat="tab in tabs"
			tabindex="0"
			ng-class="{active: tab.active}"
			tab-content-transclude="tab">
			</div>
		</div>
	</div>
</script>
							
						

Démonstration

Corrections

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);.

						
a11yBootstrapModule.directive('tooltipPopup', ['getUID',function(getUID){
  return {
    link: function($scope, iElm) {

      //Remove tooltip on keyup ESC
      function dismissTooltip (e) {
        if(e.keyCode === 27){
          iElm.remove();
        }
      }
      originElement.bind('keyup', dismissTooltip);

      $scope.$on('$destroy',function() {
        originElement.unbind('keyup', dismissTooltip);
      });

    }
  };
}]);
						
					

Démonstration

Ce contenu est défini dans le template.

La taille du panneau s'adapte au contenu.

{{item}}
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.

							
angular.module('template/accordion/accordion-group.html', []).run(['$templateCache', function($templateCache) {
  $templateCache.put('template/accordion/accordion-group.html',
    '<div unique-id="accordion" class="panel panel-default">\n' +
    '  <div role="tab" class="panel-heading">\n' +
    '    <h4 class="panel-title">\n' +
    '      <a aria-selected="{{isOpen}}" tabindex="{{isFocused ? \'0\' : \'-1\'}}" aria-expanded="{{isOpen}}" href class="accordion-toggle" ng-click="toggleOpen()" accordion-transclude="heading"><span ng-class="{\'text-muted\': isDisabled}">{{heading}}</span></a>\n' +
    '    </h4>\n' +
    '  </div>\n' +
    '  <div role="tabpanel" aria-hidden="{{!isOpen}}" class="panel-collapse" collapse="!isOpen">\n' +
    '   <div class="panel-body" ng-transclude></div>\n' +
    '  </div>\n' +
    '</div>\n' +
    '');
}]);

angular.module('template/accordion/accordion.html', []).run(['$templateCache', function($templateCache) {
  $templateCache.put('template/accordion/accordion.html',
    '<div role="tablist" class="panel-group" ng-transclude></div>');
}]);
							
						

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.

							
.directive('keySpace', function() {
  return function(scope, element, attrs) {
    element.bind('keydown keypress', function(event) {
      if(event.which === 32) {
        scope.$apply(function(){
          scope.$eval(attrs.keySpace);
        });
        event.preventDefault();
      }
    });
  };
})
							
						

Malgrés la correction, lors ce que la restitution est activée (Firefox + NVDA) les touches gauche et droite ne sont plus opérationnelles.

Démonstration

{{radioModel || 'null'}}

Pour ce composant, les erreurs relevées sont :

  • Le composant ne possède pas de role="radiogroup", et aucun bouton n'est sélectionné.
  • les éléments représentants les boutons radio ne possèdent pas la propriété role="radio"
  • Lorsque un élément n'est pas sélectionné, il ne possède pas la propriété aria-checked="false"
  • Lorsque un élément est sélectionné, il ne possède pas la propriété aria-checked="true"
  • Aucune interaction au clavier n'est possible avec les touches , , , ou Tab

Corrections

Gestion des propriété des composants

Pour cela, il suffit d'ajouter les propriétés role="radiogroup" et role="radio" sur les éléments lors de l'initialisation du composant.

<div class="btn-group" role="radiogroup">
	<label class="btn btn-primary" role="radio" ng-model="radioModel" btn-radio="'Gauche'">Gauche</label>
	<label class="btn btn-primary" role="radio" ng-model="radioModel" btn-radio="'Centre'">Centre</label>
	<label class="btn btn-primary" role="radio" ng-model="radioModel" btn-radio="'Droite'">Droite</label>
</div>

Gestion de l'accès au clavier

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>.

<div class="btn-group" role="radiogroup" keyboard-rotate="0">
	<button class="btn btn-primary" role="radio" ng-model="radioModel" btn-radio="'Gauche'">Gauche</button>
	<button class="btn btn-primary" role="radio" ng-model="radioModel" btn-radio="'Centre'">Centre</button>
	<button class="btn btn-primary" role="radio" ng-model="radioModel" btn-radio="'Droite'">Droite</button>
</div>

Gestion de l'interaction au clavier

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)));
      };

    }
  };

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.

						
<div role="group" aria-labelledby="titre-checkbox" class="btn-group">
	<label role="checkbox" class="btn btn-primary" ng-model="checkModel.left" btn-checkbox>Gauche</label>
	<label role="checkbox" class="btn btn-primary" ng-model="checkModel.middle" btn-checkbox>Centre</label>
	<label role="checkbox" class="btn btn-primary" ng-model="checkModel.right" btn-checkbox>Droite</label>
</div>
						
					

Pour gérer la touche espace, il suffit simplement de remplacer les balises label de la démo par des balises button.

						
<div role="group" aria-labelledby="titre-checkbox" class="btn-group">
	<button role="checkbox" class="btn btn-primary" ng-model="checkModel.left" btn-checkbox>Gauche</button>
	<button role="checkbox" class="btn btn-primary" ng-model="checkModel.middle" btn-checkbox>Centre</button>
	<button role="checkbox" class="btn btn-primary" ng-model="checkModel.right" btn-checkbox>Droite</button>
</div>
						
					

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);
      };

    }
  };
}]);
						
					

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é.


angular.module('template/datepicker/day.html', []).run(['$templateCache', function($templateCache) {
  $templateCache.put('template/datepicker/day.html',
    '<table role="grid" aria-labelledby="{{uniqueId}}-title" aria-activedescendant="{{activeDateId}}">\n' +
    '  <thead>\n' +
    '    <tr>\n' +
    '      <th><button type="button" class="btn btn-default btn-sm pull-left" ng-click="move(-1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-left"></i></button></th>\n' +
    '      <th colspan="{{5 + showWeeks}}"><button id="{{uniqueId}}-title" role="heading" aria-live="assertive" aria-atomic="true" type="button" class="btn btn-default btn-sm" ng-click="toggleMode()" tabindex="-1" style="width:100%;"><strong>{{title}}</strong></button></th>\n' +
    '      <th><button type="button" class="btn btn-default btn-sm pull-right" ng-click="move(1)" tabindex="-1"><i class="glyphicon glyphicon-chevron-right"></i></button></th>\n' +
    '    </tr>\n' +
    '    <tr>\n' +
    '      <th ng-show="showWeeks" class="text-center"></th>\n' +
    '      <th role="columnheader" ng-repeat="label in labels track by $index" class="text-center"><small aria-label="{{label.full}}">{{label.abbr}}</small></th>\n' +
    '    </tr>\n' +
    '  </thead>\n' +
    '  <tbody>\n' +
    '    <tr role="row" ng-repeat="row in rows track by $index">\n' +
    '      <td ng-show="showWeeks" class="text-center h6"><em>{{ weekNumbers[$index] }}</em></td>\n' +
    '      <td ng-repeat="dt in row track by dt.date" class="text-center" role="gridcell" id="{{dt.uid}}" aria-disabled="{{!!dt.disabled}}">\n' +
    '        <button type="button" style="width:100%;" class="btn btn-default btn-sm" aria-selected="{{dt.selected ? \'true\' : \'false\'}}" ng-class="{\'btn-info\': dt.selected, active: isActive(dt)}" ng-class="{\'btn-info\': dt.selected, active: isActive(dt)}" ng-click="select(dt.date)" ng-disabled="dt.disabled" tabindex="-1"><span ng-class="{\'text-muted\': dt.secondary, \'text-info\': dt.current}">{{dt.label}}</span></button>\n' +
    '      </td>\n' +
    '    </tr>\n' +
    '  </tbody>\n' +
    '</table>\n' +
    '');
}]);
					

Gestion de la touche [Echap]

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.


a11yBootstrapModule.directive('datepickerPopupWrap', ['$document', function($document){
  return {
    link: function($scope, iElm, iAttrs, controller) {

      var elemOpener;
      $scope.$watch('isOpen', function(value) {
        if (value) {
          elemOpener = $document[0].activeElement;
        }
      });

      function backToElemOpener (evt) {
        if (evt.which === 27) {
          $timeout(function() {
            elemOpener.focus();
          });
        }
      }
      //Add event listener
      iElm.bind('keydown', backToElemOpener);


      $scope.$on('$destroy',function() {
        //Remove event listener
        iElm.unbind('keydown', backToElemOpener);
      });
    }
  };
}]);
					

L'avis du développeur

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.

Télécharger la correction angular-ui au format JavaScript (16Ko)