JQuery + JQuery-ui Tutoriel composants d’interface JavaScript

JQuery UI components V1.12.1 + jQuery JavaScript Library v3.1.1

Méthodologie

Pour la bibliothèque jQuery-ui, on va créer des widgets qui vont étendre des composants déjà existants. Par exemple, pour créer un widget de boîte de dialogue qui va étendre le widget existant ui.dialog :

(function ($) {
	$.widget( 'ui.dialog', $.ui.dialog, {
		// code
	});
}(jQuery));

Voir la documentation des créations de widgets JQuery-ui

Une fois le widget créé, il faut éviter de ré-écrire tout le contenu des méthodes surchargées. Pour cela, on va utiliser la méthode this._super(); du même nom que le widget parent, avec les arguments spécifiés (s'il y en a). Voici la syntaxe à utiliser pour créer un widget JQuery-ui qui étend le composant ui.dialog, et qui surcharge la fonction open: function() {…}

(function ($) {
	$.widget( 'ui.dialog', $.ui.dialog, {
		open: function() {
			// surcharge avant
			this._super(); // lance la fonction 'open' du widget ui.dialog parent
			// surcharge après
		}
	});
}(jQuery));

Démonstration

Du contenu avec un lien avant le composant

Description du composant

en attente...

Région mise à jour par la progressbar

Du contenu avec un lien après le composant

Pour ce composant, les erreurs relevées sont :

  • l’absence de titre du composant, aucun titre ne sera vocalisé lors de la prise de focus sur le composant ;
  • l’absence de valeur enrichie qui sera verbalisée à la place de la valeur courante de la barre de progression (par exemple, si la barre de progression est à 20%, les TA doivent pouvoir verbaliser “chargement en cours 20% - copie des fichiers”) et enfin si la barre de progression décrit la progression du chargement d’une zone particulière d’une page ;
  • la zone concernée doit pouvoir être informée de l’état de la progression, et l’internaute doit être informé que la zone concernée par la mise à jour est en train d’être modifiée.

Correctifs appliqués

Pour corriger les problèmes d’accessibilité, nous avons ajouté quatre nouveaux arguments lors de l’initialisation de la barre de progression :

  • ariaValuetextSuffix et ariaValuetextPrefix afin de pouvoir créer la valeur enrichie à partir de la valeur aria-value et ainsi la préfixer et la suffixer ;
  • region : afin de définir si une zone particulière est mise à jour par le chargement de la barre de progression ;
  • labelledby : afin de définir une alternative au composant, ce paramètre peut être une chaîne de caractères ou alors une référence à un nœud du DOM.
$( "#progressbar").progressbar({
	ariaValuetextSuffix : 'suffix',
	ariaValuetextPrefix : 'prefix',
	region : $('#region'),
	labelledby : $('#description-composant')
});

Création de l'extension du module Progressbar :

(function ($) {
	'use strict';
	// Progressbar Extension
	// ===============================
	$.widget( 'ui.progressbar', $.ui.progressbar, {

	});
}(jQuery));

Gestion de la description du composant

Afin que le composant ait une description, on va étendre la fonction _create(){…} et tester si l’argument labelledby est défini, et si c’est le cas, on va de nouveau tester si c’est une chaîne de caractères :

  • si c'est une chaîne de caractères, on va ajouter un attribut title ;
  • sinon on ajoute l’attribut aria-labelledby.
(function($) {
    $.widget('ui.progressbar', $.ui.progressbar, {
        _create: function(event, index) {

            // Si un labelledby est défini
            if (typeof(this.options.labelledby !== typeof undefined)) {
                if (jQuery.type(this.options.labelledby) === 'string') {
                    // si la région est définie en string, on ajoute un attr 'title'
                    this.element.attr('title', this.options.labelledby);
                } else if (jQuery.type(this.options.labelledby) === 'object') {
                    // sinon, on ajout l'id du node associé dans le aria-labelledby
                    this.element.attr('aria-labelledby', this.options.labelledby[0].id);
                }
            }

            // appel de la fonction _create du composant étendu
            this._super(event, index);

        },

    });
}(jQuery));

Gestion d’une zone mise à jour par la barre de progression

Si l’attribut region est défini, on va ajouter un attribut aria-describedby sur cette zone afin de décrire la zone par le composant. À chaque fois qu’on mettra à jour la valeur de progression du composant, on va vérifier si la progression est en cours, on va ajouter l’attribut aria-busy et le définir à true. Quand la valeur de la barre de progression est à son maximum, on va de nouveau définir l’attribut aria-busy à false.

(function($) {
    $.widget('ui.progressbar', $.ui.progressbar, {
        _create: function(event, index) {

            // Si une région est définie
            if (typeof(this.options.region !== typeof undefined)) {
                // si la région est définie en string, on recherche le node avec l'id associé
                if (jQuery.type(this.options.region) === 'string') {
                    this.options.region = $('#' + this.options.region);
                }
                this.options.region.attr('aria-describedby', this.element[0].id);
            }

            // appel de la fonction _create du composant étendu
            this._super(event, index);

        },
        _refreshValue: function(event, index) {

            // appel de la fonction _refreshValue du composant étendu
            this._super(event, index);

            if (typeof(this.options.region !== typeof undefined)) {
                if (this.options.value === this.options.max) {
                    // Suppression de l'attribut aria-busy si on est arrivés au bout de la progressbar
                    this.options.region.attr('aria-busy', false);
                } else if (!this.indeterminate && this.options.value !== 0) {
                    this.options.region.attr('aria-busy', true);
                }
            }

        }
    });
}(jQuery));

Gestion de la description enrichie

Si les arguments ariaValuetextPrefix et ariaValuetextSuffix sont définis, on les utilise pour créer une description enrichie à partir de la valeur courante de la barre de progression en tant que préfixe et suffixe de cette valeur. Puis on met à jour cette valeur à chaque modification de la valeur du composant. On fait attention à bien supprimer cet attribut lorsque la barre de progression n’est pas ou plus active (c’est-à-dire que la progression est terminée ou pas encore lancée). On en profite aussi pour corriger une petite anomalie qui correspond à la non définition de l’attribut aria-valuemax s’il en existe un.

(function($) {
    $.widget('ui.progressbar', $.ui.progressbar, {
        _create: function(event, index) {

            if (typeof(this.element.attr('aria-valuemax')) === typeof undefined) {
                this.element.attr('aria-valuemax', this.options.max);
            }

            // appel de la fonction _create du composant étendu
            this._super(event, index);

        },
        _destroy: function(event, index) {

            // Suppression de l'attribut aria-valuetext si on détruit la barre de progression
            var attr = this.element.attr('aria-valuetext');
            if (typeof attr !== typeof undefined && attr !== false) {
                this.element.removeAttr('aria-valuetext');
            }

            // appel de la fonction _destroy du composant étendu
            this._super(event, index);

        },
        _refreshValue: function(event, index) {

            // appel de la fonction _refreshValue du composant étendu
            this._super(event, index);

            // Mise à jour de l'attribut aria-valuetext
            if (!this.indeterminate) {
                var valuetext = this.options.value;
                if (typeof(this.options.ariaValuetextPrefix !== typeof undefined)) {
                    valuetext = this.options.ariaValuetextPrefix + ' ' + valuetext;
                }
                if (typeof(this.options.ariaValuetextSuffix !== typeof undefined)) {
                    valuetext += ' ' + this.options.ariaValuetextSuffix;
                }
                this.element.attr({
                    'aria-valuetext': valuetext
                });
            }

        }
    });
}(jQuery));

L'utilisation sous VoiceOver peut être rendue plus ergonomique en forçant le focus sur la barre de progression une fois celle-ci commencée. La zone de progression sera alors restituée sans manipulation clavier supplémentaire.

Démonstration

Du contenu avec un lien avant le composant

Du contenu avec un lien entres les composants

Titre du slider vertical via aria-labelledby

Du contenu avec un lien après le composant

Pour ce composant, les erreurs relevées sont :

  • aucun motif de conception n'est défini (role="slider"), ce qui implique que les TA ne savent pas comment implémenter leur assistance ;
  • l’absence de titre du composant : aucun titre ne sera vocalisé lors de la prise de focus sur le composant ;
  • l'absence d'indication de valeurs minimale et maximale du slider, ainsi que la valeur courante de la position du slider
  • l’absence de valeur enrichie qui doit être 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 ;
  • l’absence d'attribut aria-orientation="vertical" pour indiquer que le slider est vertical lorsque c'est nécessaire.

Correctifs appliqués

Pour corriger les problèmes d’accessibilité, nous avons ajouté deux nouveaux arguments lors de l’initialisation du slider :

  • ariaValuetext afin de pouvoir créer la valeur enrichie à partir de la valeur aria-value et ainsi la préfixer et la suffixer ;
  • label : afin de définir une alternative au composant, ce paramètre peut être une chaîne de caractères ou alors une référence à un nœud du DOM. Cet attribut doit être un tableau afin de pouvoir définir deux valeurs dans le cas des sliders à intervalles. Même si ces derniers ne rentrent pas dans le cas de l'étude, on ne souhaite pas induire d'anomalies pour cette configuration de slider.
$( "#slider" ).slider({
	ariaValuetext: '€',
	label: ['curseur simple'] // chaine de caractères
});

$( "#slidervertical" ).slider({
	ariaValuetext: '$',
	orientation: "vertical",
	label: [$('#slider_label')] // Référence à un noeud du DOM
});

Création de l'extension du module Slider :

(function ($) {
  $.widget( 'ui.slider', $.ui.slider, {

  });
}(jQuery));

Définition du motif de conception

On va étendre la fonction _createHandles(){…} pour ajouter un attribut role="slider" sur le composant.

$.widget( 'ui.slider', $.ui.slider, {
    _createHandles: function () {
      this._super();
      var  attrHandle,
      options = this.options;
      this.handles.each(function(index) {
        //Set constant attribut
        attrHandle = {
          'role':'slider'
        };

        $(this).attr(attrHandle);
      });
    }
  });

Définition du titre du composant

On va étendre la fonction _createHandles(){…} et tester si l’argument labelledby est défini, et si c’est le cas, on va de nouveau tester si c’est une chaîne de caractères :

  • si c'est le cas, on ajoute un attribut title sur le composant ;
  • sinon on ajoute l’attribut aria-labelledby sur le composant.

Attention, à la différence du composant progressbar, cet argument est un tableau JavaScript, par exemple : labelledby: ['value 1', 'value 2'] ; afin de rester compatible avec les sliders à intervalles (même s'ils ne rentrent pas dans le cadre cette étude) et de ne pas attribuer deux fois le même titre sur les deux curseurs du composant.

$.widget( 'ui.slider', $.ui.slider, {
    _createHandles: function () {
      this._super();
      var  attrHandle,
      options = this.options;
      this.handles.each(function(index) {
      if (typeof(options.label[index] !== typeof undefined)) {
          if(jQuery.type(options.label[index]) === 'string') {
            attrHandle.title =  options.label[index];
          }else if(jQuery.type(options.label[index]) === 'object' && options.label[index].length > 0){
            attrHandle['aria-labelledby'] =  options.label[index][0].id;
          }
        }

        $(this).attr(attrHandle);
      });
    }
  });

Définition des valeurs minimale et maximale du composant, mise à jour de la valeur courante et de la valeur enrichie et définition d'un attribut pour indiquer que le slider est vertical est nécessaire.

Lors de la création des curseurs, on va leur définir les attributs constants aria-valuemin et aria-valuemax. Si le slider a une valeur définie par défaut, c'est aussi à ce moment qu'on va générer les attributs aria-valuenow et aria-valuetext (ce dernier n'est défini que si l'argument ariaValuetext est défini).

La mise à jour des attributs concernant la valeur courante et la valeur enriche se faisant à plusieurs endroits, cela a été externalisé dans une fonction _updateHandles{…} qui est appelée lors de la création des curseurs, et dans la fonction liée a leur déplacement (_slide{…}).

La définition de l'attribut concernant l'orientation du slider n'est définie que si le slider est vertical, dans ce cas on ajoute l'attribut aria-orientation="vertical".

$.widget( 'ui.slider', $.ui.slider, {
    _createHandles: function () {
      this._super();
      var newVal, attrHandle,
      self = this,
      options = this.options;
      this.handles.each(function(index) {
        //Set constant attribut
        attrHandle = {
          'role':'slider',
          'aria-valuemin':options.min,
          'aria-valuemax':options.max,
        };

	if(options.orientation === "vertical") {
		attrHandle['aria-orientation'] =  'vertical';
	}

        $(this).attr(attrHandle);

        //Set live attribut
        if ( options.values && options.values.length ) {
          newVal = self.values( index );
        } else {
          newVal = self.value();
        }
        self._updateHandles(index, newVal);
      });
    },
    _slide: function(event, index, newVal) {
      this._super(event, index, newVal);
      //Set live attribut
      this._updateHandles(index, newVal);
    },
    _updateHandles: function(index, newVal) {
      var options = this.options,
          attrHandle = {};

      if (options.ariaValuetext) {
            attrHandle['aria-valuetext'] =  newVal + ' ' + options.ariaValuetext;
      }

      attrHandle['aria-valuenow'] =  newVal;
      $(this.handles[index]).attr(attrHandle);
    }
  });

Démonstration

Du contenu avec un lien avant lien qui ouvre le composant

Ouvrir

Du contenu avec un lien après le lien qui ouvre le composant

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

lorem ipsum focus me

Du contenu avec un lien après le composant

Pour ce composant, les erreurs relevées sont :

  • Lors de l'ouverture du composant, le focus n'est pas donné sur le premier élément focusable, cela pertube les utilisateurs de clavier ou d'autres TA. Car lors de l'ouverture d'un composant dialog, le contexte change, et la navigation au clavier est restreinte à l'intérieur du composant. Il faut donc qu'on place le focus sur le premier élément focusable comme si on naviguait dans une page à part entière.

Correctifs appliqués

Pour corriger le problème d’accessibilité, il faut donc pouvoir identifier chaque composant de dialog. Pour ce faire, nous allons utiliser un argument déjà existant, dialogClass afin d'identifier la boîte de dialogue.

Attention, il faut être vigilant, car on utilise une class pour identifier la boîte de dialogue, ce qui n'est pas un élément unique dans la page. Mais l'argument pour attribuer une class est déjà présent dans la biliothèque du composant dialog, à la différence d'un identifiant.

$( "#dialog" ).dialog({
	dialogClass: "id-dialog",
	autoOpen: false
});

Création de l'extension du module Dialog :

(function ($) {
	$.widget( 'ui.dialog', $.ui.dialog, {

	});
}(jQuery));

Gestion du focus lors de l'ouverture de la boîte de dialogue

Lors de l'ouverture de la boîte de dialogue et après le test de la présence de l'attribut dialogClass, on redonne le focus sur le premier élément trouvé focusable.

(function ($) {
	$.widget( 'ui.dialog', $.ui.dialog, {

		open: function() {
			this._super();

			if (this.options.dialogClass !== null && this.options.dialogClass !== '') {
				var elementsFocusable = $('.' + this.options.dialogClass + ' :focusable');
				if (elementsFocusable[0]) {
					elementsFocusable[0].focus();
				}
			}
		}

	});
}(jQuery));

Démonstration

Du contenu avec un lien avant le composant

Du contenu avec un lien après le composant

Pour ce composant, les erreurs relevées sont :

  • Le composant ne possède pas la propriété role="combobox" ;
  • Le composant ne possède pas la propriété aria-haspopup="false" ;
  • Le champ de saisie ne possède pas la propriété role="textbox" ;
  • Le champ de saisie ne possède pas la propriété aria-autocomplete="list" ;
  • Le champ de saisie ne possède pas la propriété aria-activedescendant="ID_option_selectionnée" ;
  • Le champ de saisie ne possède pas la propriété aria-owns="ID_liste".

Solution alternative

Pourquoi avoir choisi une solution alternative ?

Le nombre d'erreurs de ce composant, et la complexité de la mise en œuvre d'une correction ne nous permettent pas la correction de ce composant. L'alternative à un composant d'autocomplétion peut-être très variable en fonction de la nature de l'autocomplétion. Selon les cas il peut s'agir :

  • D'une liste déroulante (balise HTML select) la recherche par autocomplétion est alors remplacée par la recherche par caractères dans une liste déroulante ;
  • D'une page regroupant tous les termes impactés par l'autocomplétion et traités sous la forme de liens de requête serveur. Là aussi le système d'autocomplétion est alors remplacé par la recherche plein texte dans la liste des liens ;
  • Utiliser un élément datalist HTML 5, qui permet de monter une autocomplétion "accessible" mais, malheureusement son support est encore très limité par les navigateurs (voir le support de la balise datalist).

Démonstration

Du contenu avec un lien avant le composant

par exemple : 22/07/1984 :

Du contenu avec un lien après le composant

Pour ce composant, les erreurs relevées sont :

  • L’absence de description du composant, et de sémantique adaptée au composant, ce qui ne permet pas aux TA de savoir si une date est sélectionnée ou pas, ni de connaître le jour de la semaine et le mois lié à une date lorsqu'on parcourt le tableau :
    • Chaque groupe de titres de jours et de numéros ne possède pas d'attribut role="grid" ;
    • Chaque groupe de titres de jours et de numéros ne possède pas la propriété aria-labelledby="[ID_titre]" référençant le passage de texte contenant le mois et l'année du calendrier en cours. Donc lorsqu'une synthèse vocale passera sur les numéros de jour, aucune information sur le mois du jour qu'il est en train de parcourir ne sera donnée ;
    • 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” ;
    • Aucun numéro de jour ne possède un attribut role="gridcell" ;
    • Pour caque jour sélectionné il ne possède pas la propriété aria-selected="true", et inversement.
  • Non conformité de l'ensemble des interactions clavier :
    • De l'extérieur du composant, l'utilisation de la touche Tabulation ne donne pas le focus sur le jour en cours ;
    • Depuis le composant, l'utilisation de la touche Tabulation ne donne pas le focus sur l'élément focusable suivant à l'extérieur du composant ;
    • Depuis un numéro de jour, l'utilisation de la touche de direction ne déplace pas le focus sur le même jour de la semaine précédente ;
    • Depuis un numéro de jour, l'utilisation de la touche de direction ne déplace pas le focus sur le même jour de la semaine suivante ;
    • Depuis un numéro de jour, l'utilisation de la touche de direction ne déplace pas le focus sur le jour suivant ;
    • Depuis un numéro de jour, l'utilisation de la touche de direction ne déplace pas le focus sur le jour précedent ;
    • Si le calendrier est une fenêtre, la touche Echap ne permet pas de fermer la fenêtre ;
    • Lors de l'utilisation de la touche Echap, le focus n'est pas redonné, si nécessaire, à l'élément ayant ouvert le calendrier ;
    • Si le calendrier est une fenêtre rattachée au champ, le focus n'est pas redonné au champ mis à jour lors de l'utilisation de la touche Entrée.

Solution alternative appliquée

  • Le datepicker doit être caché au lecteur d'écran, pour ne pas avoir à vocaliser l'intégralité du tableau de dates, grâce à l’attribut aria-hidden="true" ainsi que pour le bouton d'accès au composant ;
  • Interdire la navigation au clavier dans le composant avec l’attribut tabindex="-1" ainsi que pour le bouton d'accès au composant ;
  • Lors de la sélection d'une date, on replace le focus dans le champ lié au composant.

Pourquoi avoir choisi une solution alternative ?

Étant donné le volume d'erreurs, et considérant que ce calendrier n'apporte pas d'informations (cf. il n'indique pas une sélection de jours réservables ou disponibles, toutes les dates sont sélectionnables), il est considéré comme un outil de confort ou d'assistance à la saisie. De ce fait, il est préférable de rendre invisible ce calendrier pour les personnes naviguant au clavier ou utilisant des TA plutôt que d'avoir à parcourir au clavier ou à vocaliser un tableau de dates relativemenet long et mal formaté ;

Néanmoins, pour les personnes n'ayant pas la possibilité d'avoir l'aide de ce calendrier de saisie, il faut leur indiquer quel est le format de date attendu dans l'étiquette du champ de formulaire, et fournir un exemple réel de saisie par un texte relié via la propriété aria-describedby.

Mise en place de la solution alternative

Lors de l'appel du composant, l'attribut onClose est utilisé pour redonner le focus au champ de formulaire lorsque le composant est fermé : $( '#datepicker' ).focus();.

Dans notre exemple, le bouton d'ouverture du calendrier doit être rendu inaccessible au clavier : $('#datepicker').next().attr('tabindex', '-1');, et le calendrier lui-même ne doit pas être vocalisé : $('#ui-datepicker-div').attr('aria-hidden', 'true');, et les élements focusables ne doivent plus l'être : $('#ui-datepicker-div :focusable').attr('tabindex', '-1');.

(function ($) {

	$( '#datepicker' ).datepicker({
		showOn: 'button',
		buttonText: 'Choisir une date',
		onClose : function(){
			$( '#datepicker' ).focus();
		}
	});

	// Interdiction de la navigation au clavier dans le composant
	$('#datepicker').next().attr('tabindex', '-1');
	// Masquage du composant pour les technologies d'assitance
	$('#ui-datepicker-div').attr('aria-hidden', 'true');
	$('#ui-datepicker-div :focusable').attr('tabindex', '-1');

}(jQuery));

L'avis du développeur

Lors de l'écriture de ces correctifs, on peut voir qu'il est très simple de créer un widget, qui étend le fonctionnement d'un widget déjà existant afin d'en corriger le fonctionnement. Malheureusement, lorqu'il s'agit de modifier le DOM, cela peut s'avérer parfois très long, par exemple si on devait surclasser le fonction _generateHTML du widget datepicker, le temps à y passer serait très long. Donc parfois, pour certains composants dit "de confort", une solution alternative est préférable.

Consulter le dépôt des corrections pour jQuery UI