Angular-ui Tutoriel composants d’interface JavaScript

Angular UI Bootstrap V2.2.0 + AngularJs v1.5.8

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 Slider 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 ou la malformation d'une valeur enrichie, qui sera verbalisée à la place de la valeur courante du slider et qui doit ê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 ;
  • Pour la valeur enrichie, il est possible d'agir directement sur le template et modifier la valeur de l'attribut aria-valuetext ;
  • Il nous faut également modifier la directive uibRating (propriété restrict). En effet, nous voulons pouvoir utiliser cette directive en tant qu'élément et non pas en tant qu'attribut comme cela est proposé. Cela permet à la directive de contrôler son template et ainsi d'éviter d'avoir certains attributs liés à l'accessibilité tel qu'aria-labelledby sur un élément du DOM qui n'est pas tabulable et ainsi perdre des informations à la vocalisation.
<h4 id="titre-slider">Exemple de composant AngularJS</h4>>
<uib-rating aria-labelledby="titre-slider" ng-model="rate" max="max" read-only="isReadonly" on-hover="hoveringOver(value)" on-leave="overStar = null" ></uib-rating>
          

Voici le template corrigé :

            
angular.module('uib/template/rating/rating.html', []).run(['$templateCache', function($templateCache) {
  $templateCache.put(
    'uib/template/rating/rating.html',
    '<span ng-mouseleave=\"reset()\" ng-keydown=\"onKeydown($event)\" tabindex=\"0\" role=\"slider\" aria-valuemin=\"0\" aria-valuemax=\"{{range.length}}\" aria-valuenow=\"{{value}}\" aria-valuetext=\"{{value}} étoile{{ value > 1 ? \'s\' :  \'\' }}\">\n' +
    '    <span ng-repeat-start=\"r in range track by $index\" class=\"sr-only\">({{ $index < value ? \'*\' : \' \' }})</span>\n' +
    '    <i ng-repeat-end ng-mouseenter=\"enter($index + 1)\" ng-click=\"rate($index + 1)\" class=\"glyphicon\" ng-class=\"$index < value && (r.stateOn || \'glyphicon-star\') || (r.stateOff || \'glyphicon-star-empty\')\" ng-attr-title=\"{{r.title}}\"></i>\n' +
    '</span>\n' +
  '');
}]);
            
          

Et la directive :

On modifie donc le paramètre restrict: 'A' en restrict: 'E' et on ajoute la propriété replace: true afin de remplacer l'élément dans le DOM par son template.

            
a11yBootstrapModule.directive('uibRating', function() {
  return {
    priority: 10000,
    require: ['uibRating', 'ngModel'],
    restrict: 'E',
    replace: true,
    scope: {
      readonly: '=?readOnly',
      onHover: '&',
      onLeave: '&'
    },
    controller: 'UibRatingController',
    templateUrl: 'uib/template/rating/rating.html',
    link: function(scope, element, attrs, ctrls) {
      var ratingCtrl = ctrls[0], ngModelCtrl = ctrls[1];
      ratingCtrl.init(ngModelCtrl);
    }
  };
});
            
          

Démonstration

{{dynamic}}%

Région mise à jour par la progressbar


Progressbar avec valeur courante inconnue :

Corrections

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

  • La Progressbar doit posséder un attribut title ou aria-labelledby faisant office de nom ;
  • La Progressbar doit être focusable et les attributs liés à l'accessibilité ne doivent pas être dispersés sur deux éléments du DOM.

Si la Progressbar a une valeur courante inconnue :

  • Le composant ne doit pas posséder de propriété aria-valuenow ;
  • Le composant ne doit pas posséder de propriété aria-valuetext ;

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

  • Cette zone doit comporter un attribut aria-describedby="id-de-la-progress-bar" qui la lie à la Progressbar ;
  • 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 utiliser directement l'attribut title du composant qui ajoute également à la compilation une propriété aria-labelledby qui du coup n'est plus nécessaire.

On modifie également le template afin de mettre les attributs liés à l'accessibilité sur le conteneur et non pas sur l'élément utile pour le style. On ajoute également au passage un tabindex="0" afin de permettre le focus.

            
<h3 id="titre-progress">Dynamic</h3>
<uib-progressbar animate="false" title="titre-progress" value="dynamic" type="success">
  <b>{{dynamic}}%</b>
</uib-progressbar>
            
          
            
angular.module('uib/template/tabs/tabset.html', []).run(['$templateCache', function($templateCache) {
  $templateCache.put(
    'uib/template/progressbar/progressbar.html',
    '<div class=\"progress\" tabindex=\"0\" aria-labelledby=\"{{::title}}\" role=\"progressbar\" aria-valuenow=\"{{value}}\" aria-valuemin=\"0\" aria-valuemax=\"{{max}}\" aria-valuetext=\"{{percent | number:0}}%\" aria-labelledby=\"{{::title}}\">\n' +
    ' <div  class=\"progress-bar\" ng-class=\"type && \'progress-bar-\' + type\"  ng-style=\"{width: (percent < 100 ? percent : 100) + \'%\'}\" ng-transclude></div>\n' +
    '</div>\n' +
  '');
}]);
            
          

Pour se conformer au cas où la Progressbar n'a pas de valeur courante connue, on ajoute une directive permettant de supprimer les attributs aria-valuenow et aria-valuetext.

            
a11yBootstrapModule.directive('uibProgressbar', ['getUID', '$timeout',function(getUID, $timeout){
return {
  priority: 10000,
  link: function($scope, iElm) {

    function render() {
      if (iElm[0].getAttribute('value') === "") {
        angular.element(iElm).removeAttr('aria-valuenow');
        angular.element(iElm).removeAttr('aria-valuetext');
      }
    }

    $timeout(render, 0);
  }
};
}]);
            
          

Dans le cas ou la Progressbar 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.

            
<uib-progressbar animate="false" title="titre-progress" id="progressbar" value="dynamic" type="success">
  <b>{{dynamic}}%</b>
</uib-progressbar>
<div id="region" aria-busy="{{busy}}" aria-describedby="progressbar">
  <p>Région mise à 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 ;
  • À partir du titre d’un onglet, si le panneau n’est pas activé par défaut, la touche Espace ne permet pas d’activer le panneau ;
  • 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 uib/template/tabs/tabset.html. On peut ensuite ajouter le role tab sur le uib/template/tabs/tab.html. Pour corriger aria-selected="true" sur le template uib/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'}}".

              
angular.module('uib/template/tabs/tabset.html', []).run(['$templateCache', function($templateCache) {
    $templateCache.put(
      'uib/template/tabs/tabset.html',
      '<div>\n' +
      '<ul role="tablist" class="nav nav-{{type || \'tabs\'}}" ng-class="{\'nav-stacked\': vertical, \'nav-justified\': justified}" ng-transclude></ul>\n' +
      '<div class="tab-content">\n' +
        '<div role="tabpanel" class="tab-pane"\n' +
        'ng-repeat="tab in tabset.tabs"\n' +
        'tabindex="0"\n' +
        'ng-class="{active: tabset.active === tab.index}"\n' +
        'uib-tab-content-transclude="tab">\n' +
        '</div>\n' +
      '</div>\n' +
    '</div>\n' +
      '');
}]);

angular.module('uib/template/tabs/tab.html', []).run(['$templateCache', function($templateCache) {
   $templateCache.put(
    'uib/template/tabs/tab.html',
    '<li role="tab" ng-class="{active: active, disabled: disabled}" aria-selected="{{active ? \'true\' : \'false\'}}">\n' +
    '<a tabindex="{{active ? \'0\' : \'-1\'}}" href ng-click="select()" tab-heading-transclude>{{heading}}</a>\n' +
  '</li>\n' +
    '');
}]);
              
            

Pour corriger aria-controls="id-du-panneau" et aria-labelledby="id-de-l-onglet", il faut procéder en 2 étapes : d'abord créer un id unique, puis 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('uibTabset', ['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, puis affecter et ajouter l'attribut aria-controls au tab correspondant.

              
a11yBootstrapModule.directive('uibTabset', ['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 et autoClick à 0. On a donc ajouté keyboard-rotate="{recursion: 1, autoClick: 0}" à notre template. Si le but est d'activer automatiquement l'onglet à la navigation, il faut alors mettre le paramètre autoClick à 1.

              
angular.module('uib/template/tabs/tabset.html', []).run(['$templateCache', function($templateCache) {
    $templateCache.put(
      'uib/template/tabs/tabset.html',
      '<div>\n' +
      '<ul role="tablist" keyboard-rotate="{recursion: 1, autoClick: 0}" class="nav nav-{{type || \'tabs\'}}" ng-class="{\'nav-stacked\': vertical, \'nav-justified\': justified}" ng-transclude></ul>\n' +
      '<div class="tab-content">\n' +
        '<div role="tabpanel" class="tab-pane"\n' +
        'ng-repeat="tab in tabset.tabs"\n' +
        'tabindex="0"\n' +
        'ng-class="{active: tabset.active === tab.index}"\n' +
        'uib-tab-content-transclude="tab">\n' +
        '</div>\n' +
      '</div>\n' +
    '</div>\n' +
      '');
}]);
              
            

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.

Pour corriger et manipuler le DOM, il faut créer une directive du même nom d'origine, uibTooltipPopup. Lors de la création du 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 au 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('uibTooltipPopup', ['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);
    // Add aria-describedby on previousElementSibling
    var originElement = angular.element(iElm[0].previousElementSibling);
    originElement.attr('aria-describedby', idtooltip);

    // remove aria-describedby on destroy
    $scope.$on('$destroy',function() {
      originElement.removeAttr('aria-describedby');
    });

    }
  };
}]);
            
          

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 :

  • Le composant possède la propriété aria-multiselectable="true" ;
  • De l'extérieur du composant, lorsqu'un panneau est actif, le focus est donné sur le titre du premier panneau actif ;
  • 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 ;
  • Les attributs liés à l'accessibilité doivent être sur un unique élément focusable afin d'être restitués par les technologies d'assistance.

On peut ajouter à l'aide d'une directive l'attribut aria-multiselectable="true" selon que l'on veuille ou non, ouvrir plusieurs panneaux en même temps. Cela dépend de la valeur de l'attribut close-others. Si celui-ci est présent alors on peut ouvrir plusieurs panneaux, sinon le comportement par défaut ferme les autres panneaux à l'ouverture.

              
angular.directive('group', function ($timeout) {
  return {
    restrict:'EA',
    controller:'GroupController',
    priority: 100000,
    link: function(scope, element, attrs, groupCtrl) {
      $timeout(function() {

        const closeOthers = element[0].parentElement.getAttribute('close-others');
        if (closeOthers) {
          element.attr('aria-multiselectable', 'true');
        } else {
          element.attr('aria-multiselectable', 'false');
        }
      }, 0);
    }
  };
]);
              
            

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é.
On modifie également le template en ajoutant à la balise <a> les attributs liés à l'accessibilité tel que l'attribut role="tab" et aria-selected="true|false".

              


.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;
      const index = this.groups.findIndex(function(element) {
        return element.isFocused;
      });
      this.groups.indexSelected = index !== -1 ? index : 0;
      this.changeSelected();
    };

    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 ($timeout) {
  return {
    restrict:'EA',
    controller:'GroupController',
    priority: 100000,
    link: function(scope, element, attrs, groupCtrl) {
      $timeout(function() {
        groupCtrl.initFocusable();

        const closeOthers = element[0].parentElement.getAttribute('close-others');
        if (closeOthers) {
          element.attr('aria-multiselectable', 'true');
        } else {
          element.attr('aria-multiselectable', 'false');
        }
      }, 0);
    }
  };
})

// 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 = scope.$parent.isOpen ? true : 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();
      });

    }
  };
}])
              
            

Voici le template final corrigé avec l'ajout de ces directives.
Afin que le focus soit donné sur le titre du premier panneau actif, des tabindex sont utilisés. Leur valeur est mise à jour en fonction de la valeur de l'attribut isFocused.
L'attribut isOpen={{isOpen}} a également été ajouté afin de pouvoir bénéficier de l'état du panneau dans la directive groupItem et ainsi initialiser correctement le contrôleur.

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

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

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" uib-btn-radio="'Gauche'">Gauche</label>
  <label class="btn btn-primary" role="radio" ng-model="radioModel" uib-btn-radio="'Centre'">Centre</label>
  <label class="btn btn-primary" role="radio" ng-model="radioModel" uib-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="{recursion: 0, autoClick: 1}">
  <button class="btn btn-primary" role="radio" ng-model="radioModel" uib-btn-radio="'Gauche'">Gauche</button>
  <button class="btn btn-primary" role="radio" ng-model="radioModel" uib-btn-radio="'Centre'">Centre</button>
  <button class="btn btn-primary" role="radio" ng-model="radioModel" uib-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 (en appuyant sur Espace ou automatiquement suivant la configuration) 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 correctement au clavier dans cette liste grace aux touches , , , . Le paramètre autoClick permet de simuler un click automatiquement lors de la navigation lorsqu'il est à 1. Dans le cas contraire il faut appuyer sur la touche Espace afin de simuler un click

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, clickOption} = $scope.param;

      $timeout(function(){
        function KeyTrap (evt) {
          var next;
          var keyCode = evt.keyCode;

          if (clickOption === 1 && keyCode === 32) {
            evt.target.click();
          }
          //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.focus();

            if (clickOption === 0) {
              next.click();
            }

          }
        }
        angular.element(iElm[0]).on('keydown', KeyTrap);
      },0);
    }
  };
}]).directive('uibBtnRadio', [function() {
  return {
    require: ['uibBtnRadio', '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.uibBtnRadio));

        iElm.attr('aria-checked', check);
        if (ngModelCtrl.$modelValue !== '') {
          iElm.attr('tabindex', '-1');
        }
        if (check) {
          iElm.attr('tabindex', '0');
        }
        iElm.toggleClass(buttonsCtrl.activeClass, angular.equals(ngModelCtrl.$modelValue, $scope.$eval(iAttrs.uibBtnRadio)));
      };

    }
  };

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 ajouté 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" uib-btn-checkbox>Gauche</label>
  <label role="checkbox" class="btn btn-primary" ng-model="checkModel.middle" uib-btn-checkbox>Centre</label>
  <label role="checkbox" class="btn btn-primary" ng-model="checkModel.right" uib-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" uib-btn-checkbox>Gauche</button>
  <button role="checkbox" class="btn btn-primary" ng-model="checkModel.middle" uib-btn-checkbox>Centre</button>
  <button role="checkbox" class="btn btn-primary" ng-model="checkModel.right" uib-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ée, pour surcharger la contrôleur d'origine.

            
a11yBootstrapModule.directive('uibBtnCheckbox', [function() {
  return {
    require: ['uibBtnCheckbox', '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" ;
  • Pour chaque jour, il ne possède pas d'attribut aria-selected suivant la selection en cours.

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('uib/template/datepicker/day.html', []).run(['$templateCache', function($templateCache) {
  $templateCache.put(
    'uib/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 uib-left\" ng-click=\"move(-1)\" tabindex=\"-1\"><i aria-hidden=\"true\" class=\"glyphicon glyphicon-chevron-left\"></i><span class=\"sr-only\">previous</span></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 uib-title\" ng-click=\"toggleMode()\" ng-disabled=\"datepickerMode === maxMode\" tabindex=\"-1\"><strong>{{title}}</strong></button></th>\n' +
    '      <th><button type=\"button\" class=\"btn btn-default btn-sm pull-right uib-right\" ng-click=\"move(1)\" tabindex=\"-1\"><i aria-hidden=\"true\" class=\"glyphicon glyphicon-chevron-right\"></i><span class=\"sr-only\">next</span></button></th>\n' +
    '    </tr>\n' +
    '    <tr>\n' +
    '      <th ng-if=\"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 class=\"uib-weeks\" ng-repeat=\"row in rows track by $index\" role=\"row\">\n' +
    '      <td ng-if=\"showWeeks\" class=\"text-center h6\"><em>{{ weekNumbers[$index] }}</em></td>\n' +
    '      <td aria-selected="{{dt.selected}}" ng-repeat=\"dt in row\" class=\"uib-day text-center\" role=\"gridcell\"\n' +
    '        id=\"{{::dt.uid}}\"\n' +
    '        ng-class=\"::dt.customClass\">\n' +
    '        <button type=\"button\" class=\"btn btn-default btn-sm\"\n' +
    '          uib-is-class=\"\n' +
    '            \'btn-info\' for selectedDt,\n' +
    '            \'active\' for activeDt\n' +
    '            on dt\"\n' +
    '          ng-click=\"select(dt.date)\"\n' +
    '          ng-disabled=\"::dt.disabled\"\n' +
    '          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' +
    '');
}]);
          

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ée à 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ée de la philosophie d'AngularJs et le mieux reste une correction complète dans la bibliothèque d'origine. Si la librairie de composant a encore de nombreuses erreurs; le framework AngularJs a fait un réel effort pour l'accessibilité à partir de la version 1.3 avec l'apparition de ngAria. La librairie AngularJs Bootstrap a également fourni des efforts en ce sens avec de nombreuses corrections et possède désormais un composant totalement accessible nativement (modal). Avec les dernières versions des bibliothèques, la plupart des corrections à apporter sur les composants sont mineures.

Consulter le dépôt des corrections pour Angular UI Bootstrap