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