React Bootstrap

React 15.4.1 - React Bootstrap 0.30.7

Méthodologie

Pour rendre accessibles les différents composants fournis par React Bootstrap, nous avons choisi de les encapsuler dans de nouveaux composants, afin de leur ajouter des fonctionnalités en présentant la même API. Cela s'inscrit également dans la philosophie de React, qui favorise la composition plutôt que l'héritage.

La technique de base utilisée pour encapsuler les composants se présente comme suit.

On dispose d'un composant simple qui place ses enfants dans une balise <div />.

								
var Original = React.createClass({

	render: function() {
		return (
			<div {...this.props}>
				{this.props.children}
			</div>
		);
	}
});

// utilisation
<Original id="test">
	<p>I am a component</p>
</Original>

// rendu
<div id="test">
	<p>I am a component</p>
</div>
								
							

Pour encapsuler ce composant, on lui passe simplement toutes les propriétés et les enfants du wrapper. Pour l'exemple, on ajoute ici du texte supplémentaire dans la balise <div />.

								
var Wrapper = React.createClass({

	render() {
		return (
			<Original {...this.props}>
				<p>Hello</p>
				{this.props.children}
			</Original>
		);
	}
});

// utilisation
<Wrapper id="test">
	<p>I am a component</p>
</Wrapper>

// rendu
<div id="test">
	<p>Hello</p>
	<p>I am a component</p>
</div>
								
							

Ces deux composants sont donc interchangeables, et on évite de modifier le code de la bibliothèque.

Accordion

Démonstration

Corrections

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

  • Le composant doit posséder un attribut aria-multiselectable="true" ;
  • Chaque panneau doit posséder un attribut aria-labelledby="id-du-titre" pour le lier à son titre ;
  • 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.

Le composant Accordion original ne permet que l'ouverture d'un seul panneau à la fois. Nous allons commencer par ajouter le support de la sélection multiple en utilisant le state pour conserver l'état des différents panneaux. La clé activeKeys contiendra un tableau des panneaux ouverts.

								
var AccessibleAccordion = React.createClass({

	// voir https://github.com/react-bootstrap/react-bootstrap/blob/v0.30.7/src/PanelGroup.js#L29
	getInitialState() {
		return {
			activeKeys: this.props.defaultActiveKeys || []
		};
	}
});
								
							

Ensuite, on crée la méthode render(), chargée d'ajouter les propriétés requises aux panneaux enfants. La majorité de ce code est copiée depuis la méthode render() de PanelGroup, à l'exception des lignes commentées. Le fait que la gestion du state soit faite à l'intérieur du composant nous empêche d'intervenir de l'extérieur, et nous oblige à le réimplémeter.

								
	// voir https://github.com/react-bootstrap/react-bootstrap/blob/v0.30.7/src/PanelGroup.js#L48
	render() {
		var splittedProps= splitBsPropsAndOmit(this.props, ['onSelect']);
		var bsProps = splittedProps[0];
		var elementProps = splittedProps[1];
		var className = classNames(this.props.className, getClassSet(bsProps));

		return (
			<div
				{...elementProps}
				className={className}
				// au montage du composant, on appelle la méthode referenceNodes(),
				// décrite plus en détail par la suite
				ref={this.referenceNodes}
				role="tablist"
				// on ajoute l'attribut aria-multiselectable="true"
				aria-multiselectable
			>
				{ValidComponentChildren.map(
					this.props.children,
					function(child) {
						return cloneElement(child, {
							bsStyle: child.props.bsStyle || bsProps.bsStyle,
							headerRole: 'tab',
							panelRole: 'tabpanel',
							collapsible: true,
							// on récupère l'état du panneau depuis le state.
							expanded: this.state.activeKeys.includes(child.props.eventKey),
							// on appelle la méthode handleSelect lors de la sélection
							// d'un panneau, dans laquelle on mettra à jour le state
							onSelect: createChainedFunction(
								this.handleSelect,
								child.props.onSelect
							)
						});
					},
					this
				)}
			</div>
		);
	}
								
							

Afin que l'ouverture de panneaux multiple soit fonctionnelle, il reste à implémenter la méthode handleSelect(), chargée de faire persister l'état des différents panneaux dans le state.

								
	handleSelect(eventKey) {
		// on cherche si la clé est déjà dans le state
		var activeKeys = this.state.activeKeys;
		var index = activeKeys.indexOf(eventKey);

		if (index === -1) {
			// si non, on l'ajoute, afin d'ouvrir le panneau
			activeKeys.push(eventKey);
		} else {
			// si oui, on la supprime, afin de fermer le panneau
			activeKeys.splice(index, 1);
		}

		// on met à jour le state
		this.setState({
			activeKeys: activeKeys
		});
	}
								
							

Notre accordéon est maintenant capable de gérer l'ouverture de multiples panneaux.

Pour ajouter la propriété aria-labelledby="id" aux panneaux, nous allons commencer par implémenter la méthode referenceNodes(). Dans celle-ci, on sélectionne plusieurs éléments du DOM qui nous seront utiles par la suite.

								
	referenceNodes(panelGroup) {
		this.node = React.findDOMNode(panelGroup);

		// [].slice.call permet de convertir un objet NodeList, retourné par querySelectorAll(), en
		// tableau, ce qui facilitera son parcours par la suite.
		// voir https://davidwalsh.name/nodelist-array
		this.tabs = [].slice.call(this.node.querySelectorAll('[role="tab"]'));
		this.panels = [].slice.call(this.node.querySelectorAll('[role="tabpanel"]'));
	}
								
							

Au montage du composant, on lie chaque panneau à l'onglet correspondant avec l'attribut aria-labelledby="id".

								
	componentDidMount() {
		this.setupPanelsAttributes();
	}

	setupPanelsAttributes() {
		this.tabs.forEach(function(tab, i) {
			var id = tab.getAttribute('id');

			// si l'onglet n'a pas d'identifiant, on en crée un
			if (!id) {
				id = 'tab' + i;
				tab.setAttribute('id', id);
			}

			this.panels[i].setAttribute('aria-labelledby', id);
		}, this);
	}
								
							

Pour une bonne navigation dans les onglets, il va falloir gérer plus finement le focus. Nous allons commencer par rendre uniquement les onglets sélectionnés accessibles à la tabulation.

								
	componentDidMount() {
		// ...
		this.updateTabsAttributes(); // ajouté
	}

	componentDidUpdate() {
		this.updateTabsAttributes();
	}

	updateTabsAttributes() {
		// on rend chaque onglet focusable suivant si il est
		// actif ou non
		this.tabs.forEach(function(tab) {
			this.makeFocusable(
				tab,
				tab.getAttribute('aria-selected') === 'true'
			);
		}, this);

		// si aucun panneau n'est actif, on rend le premier
		// focusable, afin qu'on puisse toujours déplacer le
		// focus sur au moins un onglet
		if (this.state.activeKeys.length === 0) {
			this.makeFocusable(this.tabs[0], true);
		}
	}

	makeFocusable(node, focusable) {
		if (focusable) {
			node.removeAttribute('tabindex');
		} else {
			node.setAttribute('tabindex', '-1');
		}
	}
								
							

Il reste à traiter la navigation au clavier dans la méthode handleKeyDown(). Les flèches et transportent le focus sur le titre précédent, les flèches et sur le titre suivant. La touche Espace ouvre ou ferme le panneau courant.

								
	handleKeyDown(event) {
		// on ne traite l'événement que si on est sur un titre
		if (event.target.getAttribute('role') !== 'tab') {
			return;
		}

		switch (event.keyCode) {
			case 37: // gauche
			case 38: // haut
				this.focusSiblingTab(-1);
				break;

			case 39: // droite
			case 40: // bas
				this.focusSiblingTab(1);
				break;

			case 32: // espace
				event.target.click();
				break;

			default:
				return;
		}

		event.preventDefault();
	}
								
							

On utilise au-dessus la méthode focusSiblingTab() qui permet de donner le focus à un titre voisin du titre courant. Elle prend en paramètre une direction : 1 pour sélectionner le suivant, -1 pour le précédent. Cette méthode utilise focusedTabIndex(), qui retourne le titre ayant actuellement le focus.

								
	focusSiblingTab(direction) {
		var index = this.focusedTabIndex() + direction;

		if (index < 0) {
			index = this.tabs.length - 1;
		}

		if (index > this.tabs.length - 1) {
			index = 0;
		}

		this.tabs[index].focus();
	}

	focusedTabIndex() {
		for (var i = 0, l = this.tabs.length; i < l; i++) {
			if (this.tabs[i] === document.activeElement) {
				return i;
			}
		}

		return 0;
	}
								
							

Progress bar

Démonstration

Région mise à jour.

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 ;
  • Un attribut aria-valuetext doit indiquer la valeur courante sous une forme lisible.

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 du contenu est en cours, la zone doit comporter un attribut aria-busy="true".

La méthode render() se contente de retourner une ProgressBar, en lui passant les propriétés nécéssaires. Elle met également en place une référence vers l'élément du DOM qui contient le composant. Avec React Bootstrap, il est possible d'empiler plusieurs barres de progressions. Nous devons donc récupérer l'élément différemment, selon si le composant est unique, contient des enfants, ou est enfant d'un autre.

								
var LabelledProgressBar = React.createClass({

	render() {
		return <ProgressBar {...this.props} ref={this.referenceNodes} />;
	}

	referenceNode(progressBar) {
		// si la progress bar est enfant d'une autre, elle retourne directement
		// une node [role="progressbar"]
		if (this.props.isChild) {
			this.node = findDOMNode(progressBar);

		// sinon, si elle n'a pas de composants enfants elle retourne une div qui
		// contient une node [role="progressbar"]
		} else if (!this.props.children) {
			this.node = findDOMNode(progressBar).childNodes[0];
		}
	}
});
								
							

Au montage du composant, on ajoute un attribut title sur l'élément qui possède le [role="progressbar"]. Étant donné la manière dont le composant est implémenté, passer une propriété ne donnerait pas le résultat attendu dans tous les cas. On ajoute donc cet attribut directement grâce à l'API du DOM.

								
	componentDidMount() {
		if (this.node) {
			this.setupTitle();
		}
	}

	setupTitle() {
		this.node.setAttribute('title', this.props.label);
	}

	render() {
		return (
			<ProgressBar
				{...this.props}
				ref={this.referenceNodes}
				title={null} // on évite de passer title en propriété
			/>
		);
	}
								
							

Pour mettre à jour la propriété aria-valuetext, le plus simple est de récupérer la valeur déjà calculée par le composant original. Ce texte n'étant pas accessible par JavaScript, on le récupère directement dans le DOM :

								
	updateText() {
		if (this.props.now === undefined) {
			this.node.removeAttribute('aria-valuetext');
		} else {
			this.node.setAttribute('aria-valuetext', this.props.label);
		}
	}
								
							

Finalement, on s'assure que le texte est mis à jour lors de chaque rendu du composant :

								
	componentDidMount() {
		if (this.node) {
			// ...
			this.updateText();
		}
	}

	componentDidUpdate() {
		if (this.node) {
			this.updateText();
		}
	}
								
							

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.

Si une zone à mettre à jour (un élément du DOM) est passée en propriété, on lui ajoute un attribut aria-describedby référencant la progress bar. On effectue cet ajout à l'initialisation du composant.

								
	setupTarget() {
		this.props.target.setAttribute('aria-describedby', this.props.id);
	},

	componentDidMount() {
		// ...
		if (this.props.target) {
			this.setupTarget();
		}
	}
								
							

Lorsque la progress bar est en cours de mise à jour, on ajoute à la zone un attribut aria-busy="true" indiquant qu'une mise à jour du contenu est en cours. On met à jour cet attribut à l'initialisation ainsi qu'à chaque rendu du composant.

								
	componentDidMount() {
		// ...
		if (this.props.target) {
			// ...
			this.updateTarget();
		}
	}

	componentDidUpdate() {
		// ...
		if (this.props.target) {
			this.updateTarget();
		}
	}

	updateTarget() {
		var min = this.props.min || ReactBootstrap.ProgressBar.defaultProps.min;
		var max = this.props.max || ReactBootstrap.ProgressBar.defaultProps.max;

		// on considère qu'une mise à jour est en cours si la
		// valeur courant est entre le minimum et le maximum
		var busy = (this.props.now > min) && (this.props.now < max);

		this.props.target.setAttribute('aria-busy', busy);
	}
								
							

Tooltip

Démonstration

Corrections

Une amélioration est nécessaire pour rendre ce composant accessible :

  • Le tooltip doit pouvoir être caché lors de l'appui sur Echap

Pour ajouter cette fonctionnalité, la méthode retenue consiste à encapsuler le composant OverlayTrigger. Cela permet d'utiliser le wrapper de la même manière que OverlayTrigger, tout en profitant des améliorations d'accessibilité.

Nous pouvons d'abord implémenter la méthode render(), chargée de rendre un OverlayTrigger. Nous gardons une référence sur ce composant pour l'implémentation des méthodes suivantes.

								
var TooltipOverlayTrigger = React.createClass({

	render() {
		return (
			<OverlayTrigger {...this.props} ref={this.referenceTrigger}>
				{this.renderChild()}
			</OverlayTrigger>
		);
	}

	referenceTrigger(trigger) {
		this.trigger = trigger;
	}
});
								
							

La méthode renderChild() retourne le tooltip passé en propriété en lui ajoutant un gestionnaire d'événements pour gérer la touche Echap.

								
	renderChild(tooltipId) {
		var child = React.Children.only(this.props.children);

		return React.cloneElement(child, {
			'onKeyDown': this.handleKeyDown
		});
	}
								
							

Finalement, il reste à implémenter le gestionnaire d'événements attaché au composant enfant. Si l'utilisateur appuie sur Echap, on clôt la modale. Pour ne pas casser la chaîne des événements, on appelle le callback onKeyDown s'il est défini.

								
	handleKeyDown(event) {
		if (event.keyCode === 27) {
			this.trigger.handleDelayedHide();
		}

		if (this.props.onKeyDown) {
			this.props.onKeyDown(event);
		}
	}
								
							

Avis du développeur

Les composants React sont très "fermés", ils ne permettent leur configuration que par des propriétés. Il peut devenir compliqué d'enrichir un composant sans le réécrire si son auteur ne prévoit pas de moyen de configurer certains comportements, ou trop peu d'événements auquels souscrire lors de changements d'état.

Il reste possible de modifier tout type de composant, mais cela peut s'avérer complexe, et relativement verbeux par rapport à un code pensé pour React depuis le début. Avant de choisir un composant, il est donc judicieux d'évaluer les options de configuration possibles (modification des attributs DOM, des classes CSS), pour faciliter son intégration dans un contexte particulier.

La bibliothèque React Bootstrap étudiée ici est encore en évolution. Depuis la version 0.19.1, précédemment étudiée dans ces tutoriels, plusieurs composant ont été totalement mis en accessibilité, et d'autres s'en sont beaucoup approché. L'accessibilité des composants semble également être une priorité pour la première version majeure de la bibliothèque.

Consulter le dépôt des corrections pour React Bootstrap