Un carrousel accessible et responsive en Vanilla JS

Publié par Fabrice le 03 juin 2019, temps de lecture estimé : 24 minutes Webmastering

Un carrousel accessible et responsive en Vanilla JS

Travaux d'accessibilité et dispositifs ARIA sur l'un des composants JavaScript les plus utilisés sur les sites web : le carrousel !

Au-delà de son utilité très discutable (voir l'article de Erik Runyon sur les statistiques des carrousels - il est, d'ailleurs, déconseillé d'aller au-delà de trois slides ou panneaux), la création d'un carrousel n'en demeure pas moins un excellent exercice de mise en accessibilité : gestion de contenu en mouvement, mise à jour via JavaScript, boutons de contrôle, etc. Il fait, en outre, partie des composants dont la demande est récurrente et insistante !

Le principe de fonctionnement d'un carrousel est connu de tous, inutile de s'attarder dessus. Par contre, la question de l'accessibilité et, en particulier, l'implémentation de dispositifs ARIA, n'est pas aussi simple qu'il n'y paraît.

L'accessibilité d'un carrousel

Les contenus en mouvement doivent être contrôlables

Le contrôle des contenus en mouvement fait partie des critères des WCAG, il faut donc implémenter un bouton pour arrêter le carrousel. Ce bouton doit-être, si possible, placé au début du code et être ainsi atteignable très rapidement via la navigation au clavier. Le tout est complété par des raccourcis clavier : dans ce cas la touche espace permet d'arrêter et de reprendre la rotation et les flèches "suivant" et "précédent" permettent de naviguer aux travers des différents panneaux. Les raccourcis permettant d'aller en avant ou en arrière sont doublés par les combinaisons ctrl + flèches "suivant" ou "précédent". En effet, les lecteurs d'écran réservent ces flèches de contrôle pour naviguer à l'intérieur du contenu de la page.
Évidemment, on préférera l'utilisation de la balise button en lieu et place des liens.

Les zones live ARIA

Celles-ci servent à signaler des changements dans une page web, mais il n'est évidemment pas question de l'indiquer lorsque le carrousel est en rotation, à moins de vouloir déranger l'utilisateur d'un lecteur d'écran. L'attribut aria-live="polite" est donc uniquement ajouté lorsque le bouton pause est activé. Ainsi, lors de l'affichage manuel d'un nouveau panneau, le lecteur d'écran lira l'intégralité de celui-ci.

Les autres attributs ARIA

aria-roledescription sur la section et sur les div englobant les panneaux permet d'indiquer le type de contenu (ou plutôt le rôle) auquel on a affaire. aria-label vient compléter ces informations avec, notamment, le numéro courant du panneau ainsi que le nombre total de panneau.

aria-controls sur les boutons relie ces derniers au contenu sur lequel il va agir.

aria-selected sur les boutons de la navigation par points indique la position courante aux lecteurs d'écran (en plus d'une indication visuelle).

Arrêt du carrousel au focus clavier et souris

À la prise de focus (au clavier ou à la souris), il est préférable que la rotation s'arrête et reprenne lors de la perte de celui-ci. Pour éviter la reprise de la rotation du carrousel lorsque qu'il est en pause, l'ajout ou le retrait de la classe btnpressed s'effectue lors de l'activation du bouton "Stop/Play", permettant ainsi de connaître le statut du composant.

En supplément

La navigation par points est ajoutée via JavaScript. Cela semble anodin, mais elle permet de visualiser la position courante et le nombre de panneaux. Cette dernière information est, par ailleurs, indiquée aux lecteurs d'écran via l'attribut aria-label de chaque panneau.

Les boutons de contrôle sont supprimés s'il n'y a qu'un seul panneau.

Le panneau actif s'implémente via la class slideactive, on peut donc démarrer le carrousel par n'importe quel panneau.

Le JavaScript fait un peu moins de 4 kilo-octets en version minifiée

La navigation mobile par glisser ou swipe n'est pas implémentée, mais le code fournit par Kirupa fonctionne très bien et peut être facilement ajouté après les fonctions de navigation au clavier.

Voir la démo sur CodePen

La base HTML du carrousel

<section id="slides" aria-roledescription="Slideshow" aria-label="Description of Carousel">
	
	<div class="slides-control">
		<button class="slides-playpause" aria-controls="slides-items" aria-label="Stop Carousel">
			<svg class="playpause" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" role="img">
				<g class="pause">
					<path d="m7.43 31c2.36 0 4.29-1.93 4.29-4.29v-21.4c0-2.36-1.93-4.29-4.29-4.29-2.36 0-4.29 1.93-4.29 4.29v21.4c0 2.36 1.93 4.29 4.29 4.29zm12.9-25.7v21.4c0 2.36 1.93 4.29 4.29 4.29s4.29-1.93 4.29-4.29v-21.4c0-2.36-1.93-4.29-4.29-4.29s-4.29 1.93-4.29 4.29z"></path>
				</g>
				<g class="play">
					<path d="m6.29 0.988c1.75-0.00742 13.3 8.37 20 12.4 2.61 1.35 2.72 3.64 0.145 5.18-6.19 3.67-18.7 12.4-20.3 12.4-1.59 0.0055-2.31-1.16-2.36-2.73 0.0104-8.27-0.0208-16.5 0.0156-24.8-0.0349-1.19 0.787-2.46 2.53-2.47z"></path>
				</g>
			</svg>		
		</button>
		<button class="slides-prev" aria-controls="slides-items" aria-label="Previous Slide">
			<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" role="img">
				<path d="m22.1 31c2.03-0.108 3.01-2.54 1.62-4.03l-10.4-10.8 10.4-11c2.34-2.26-1.13-5.73-3.39-3.39l-12 12.6c-0.903 0.904-0.937 2.36-0.0775 3.3l12 12.5c0.483 0.548 1.19 0.846 1.92 0.808z"></path>
			</svg>
 		</button>
		<button class="slides-next" aria-controls="slides-items" aria-label="Next Slide">
			<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" role="img">
				<path d="m9.9 31c-2.03-0.108-3.01-2.54-1.62-4.03l10.4-10.8-10.4-11c-2.34-2.26 1.13-5.73 3.39-3.39l12 12.6c0.903 0.904 0.937 2.36 0.0775 3.3l-12 12.5c-0.483 0.548-1.19 0.846-1.92 0.808z"></path>
			</svg>
		</button>
	</div>
		
	<div id="slides-items">
		
		<!-- Slide 1 : image link + caption -->
		<div id="slide-1" class="slide-item slideactive" role="group" aria-roledescription="Slide" aria-label="1 of 3">
			<figure>
				<a href="https://www.adilade.fr/">
					<img src="https://dummyimage.com/1200x600/E2CEFA/3f3f3f.png&text=Slide+1" alt="Image 1 alternative" width="1200" height="600">
				</a>
				<figcaption>
	            	<a href="https://www.adilade.fr/">Slide 1 Description</a>	
				</figcaption>
			</figure>
		</div>
			
		<!-- Slide 2 : image + caption -->
		<div id="slide-2" class="slide-item" role="group" aria-roledescription="Slide" aria-label="2 of 3">
			<figure>
				<img src="https://dummyimage.com/1200x600/DAFF89/3f3f3f.png&text=Slide+2" alt="Image 2 alternative" width="1200" height="600">
				<figcaption>
					<strong>Slide 2 Description.</strong><br><span lang="lat">Venerem elegerit sexus mercenariae tabernaculum matrimonii in conductae post species.</span>
				</figcaption>
			</figure>
		</div>
				
		<!-- Slide 3 : image link -->
		<div id="slide-3" class="slide-item" role="group" aria-roledescription="Slide" aria-label="3 of 3">
			<figure>
				<a href="https://fr.wikipedia.org/wiki/Anthropophobie">
					<img src="https://dummyimage.com/1200x600/FFDA7D/3f3f3f.png&text=Slide+3" alt="Image 3 alternative" width="1200" height="600">
				</a>
			</figure>
		</div>
	    								
	</div> <!-- end slides-items -->
	
</section>

La mise en forme en SASS

À adapter selon vos besoins, en particulier si vous utilisez un framework CSS !

/* Reset for the demo */
*, ::before, ::after {
    -webkit-box-sizing: inherit;
    box-sizing: inherit;
    min-width: 0;
    min-height: 0; 
}
body {
  margin: 0;
  font-size: 1.4rem;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
  line-height: 1.5;
}
img, figure  {
	max-width: 100%; 
	height: auto;
}
button {
	padding: 0;
	margin: 0;
	border-style: none;
	touch-action: manipulation;
	display: inline-block;
	border: none;
	background: none;
	cursor: pointer;	
}
/* End Reset for the demo */

/* Sass Config */
$slides_width: 1200px; 
$slides_height: 600px;
$slides_maxheight: calc(100vw / (1200 / 600));
$slides_bg: rgba(0, 0, 0, 0.7);
$slides_blue: #3A72B1;
/* End Sass Config */

#slides {
	position: relative;	
	padding-bottom: 3rem;
}
#slides, #slides-items .slide-item, figure {
	width: $slides_width;
	max-width: 100%;
	height: $slides_height;	
	max-height: $slides_maxheight;
	margin: 0 auto;	
}
#slides-items .slide-item {
	position: absolute;		

	figcaption {
		position: absolute;
		bottom: 0;
		width: $slides_width;
		max-width: calc(100% - 6.8rem);
		max-height: calc(100% - 3.4rem);
		overflow: auto;
		padding: 1.7rem 3.4rem;
		text-align: center;
		color: #fff;
		background-color: $slides_bg;	
				
		a {
			color: #fff;
			
			&:hover, &:focus, &:active {
				text-decoration: none;	
			}	
		}
	}
}

/* CSS Transition */
#slides-items .slideactive {
	opacity: 1;
	transition: opacity 3s;
	visibility: visible;
}
#slides-items .slide-item:not(.slideactive) {
	opacity: 0;
	transition: opacity 5s;
	visibility: hidden;
}

/* Control Buttons */
.slides-control button {
	background-color: $slides_bg;
	
	svg, g {
		fill: #f6f6f6;
	}
	&:hover, &:focus, &:active {
		background-color: rgba(255, 255, 255, 0.9);
		
		svg {
			fill: #474747;
		}	
	} 	
}
#slides {
	
	.slides-control button {
		position: absolute;
		z-index: 1;
	}
	.slides-prev, .slides-next {
		top: calc(50% - 3rem);
		padding: 0.6rem;
				
		svg {
			width: 1.7rem; height: 1.7rem;
		}
	}
	.slides-prev {
		left: 0;	
		border-radius: 0px 7px 7px 0px;
	}
	.slides-next {
		right: 0;
		border-radius: 7px 0px 0px 7px;
	}
	.slides-playpause {
		top: 0; right: 0;
		width: 0.7rem; height: 0.7rem;		
		padding: 0.7rem;	
		background-color: $slides_bg;	
		
		svg {
			width: 0.7rem;
			height: 0.7rem;
		}
		.play,
		.playpause.paused .pause {
			display: none;	
		}
		.playpause.paused .play {
			display: block;	
		}
	}
}
/* Dots Buttons */
#slides .slides-dots {
	position: absolute;
	bottom: 0;
	z-index: 1;
	width: $slides_width;
	max-width: 100%;
	text-align: center;
}
.slides-dots { 
	svg {
		width: 1.7rem; height: 1.7rem;
		fill: $slides_bg;
	}
	button {
		
		&:hover, &:focus, &:active, &[aria-selected="true"] {
			svg {
				fill: $slides_blue;
			}
		}
	}
}

Le code JavaScript en Vanilla JS

/*
* @Adilade Slideshow/Carousel
* @See www.adilade.fr
*
* Keyboard : 
* Previous : left arrow or ctrl + left arrow
* Next : right arrow or ctrl + right arrow
* Pause/Play : space
* 
* Free to use - No warranty
*/
var timeInterval = '7000'; // Time between slides
var carousel = document.querySelector('#slides-items');
var items = document.querySelectorAll('.slide-item');

if (carousel !== undefined && items !== undefined && carousel !== null && items !== null) {
	var itemscount = items.length;
	var btnprev = document.querySelector('.slides-prev');
	var btnnext = document.querySelector('.slides-next');
	var btnplaypause = document.querySelector('.slides-playpause');
	var btnplaypausepath = document.querySelector('.playpause');			

	if (itemscount > 1) {
		
		// Create Dots
		var dotbox = document.createElement('div');
		dotbox.classList.add('slides-dots');
		// carousel.after(dotbox); Not supported by Edge => see next line
		carousel.parentNode.insertBefore(dotbox, carousel.nextSibling);			
		for (var i=0; i<itemscount; i++) {
			dotbox.insertAdjacentHTML('beforeend', '<button aria-controls="slide-'+(i+1)+'" aria-label="Slide number '+(i+1)+'" aria-selected="'+(document.querySelector('.slideactive').getAttribute('id').slice(-1) == (i+1) ? 'true' : 'false')+'"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" role="img"><circle cx="16" cy="16" r="13" /></svg></button>');
		}	
			
		var dots = document.querySelectorAll('.slides-dots button');
		var playpause = null;
	
		function slideprev() {
			var itemcurrent = document.querySelector('.slideactive');
			var dotcurrent = document.querySelector('.slides-dots button[aria-selected="true"]');
			var prevslide = itemcurrent.previousElementSibling;
			var prevdot = dotcurrent.previousElementSibling;
			if(prevslide === null) {
				prevslide = items[itemscount - 1];
				prevdot = dots[itemscount - 1]; 
			}
			// Remove current
			itemcurrent.classList.remove('slideactive');
			dotcurrent.setAttribute('aria-selected', 'false');
					
			// Add Next
			prevslide.classList.add('slideactive');
			prevdot.setAttribute('aria-selected', 'true');
		}
		
		function slidenext() {
			var itemcurrent = document.querySelector('.slideactive');
			var dotcurrent = document.querySelector('.slides-dots button[aria-selected="true"]');
			var nextslide = itemcurrent.nextElementSibling;
			var nextdot = dotcurrent.nextElementSibling;
			if(nextslide === null) {
				nextslide = items[0];
				nextdot = dots[0];
			}
			// Remove current
			itemcurrent.classList.remove('slideactive');
			dotcurrent.setAttribute('aria-selected', 'false');
								
			// Add Next
			nextslide.classList.add('slideactive');
			nextdot.setAttribute('aria-selected', 'true');
		}

		function slidepause() {
			clearInterval(playpause);
			playpause = null;
			btnplaypause.setAttribute('aria-label', 'Play Carousel');
			btnplaypausepath.classList.add('paused');
			carousel.setAttribute('aria-live', 'polite');				
		}
		function slideplay() {
			playpause = setInterval(slidenext, timeInterval);
			btnplaypause.setAttribute('aria-label', 'Stop Carousel');
			btnplaypausepath.classList.remove('paused');
			carousel.removeAttribute('aria-live');				
		}						
		function slideplaypause() {
			if (playpause !== null) {
				slidepause();
				carousel.classList.add('btnpressed');
			} else {
				slideplay();
				carousel.classList.remove('btnpressed');
			}
		}
						
		// Dots Navigate
		[].map.call(dots, function(dot) {

			dot.addEventListener('click', function(e) {
				var itemcurrent = document.querySelector('.slideactive');
				var dotcurrent = document.querySelector('.slides-dots button[aria-selected="true"]');
				var dotclick = dot.getAttribute('aria-controls');
				var targetslide = document.querySelector('#'+ dotclick +'');
				var targetdot = document.querySelector('button[aria-controls="'+dotclick+'"]');

				// Remove current
				itemcurrent.classList.remove('slideactive');
				dotcurrent.setAttribute('aria-selected', 'false');
									
				// Add Target
				targetslide.classList.add('slideactive');
				targetdot.setAttribute('aria-selected', 'true');
		
				e.preventDefault();
			
			},false);
			
		},false);
				
		// Navigate
		btnprev.addEventListener('click', slideprev);
		btnnext.addEventListener('click', slidenext);
		btnplaypause.addEventListener('click', slideplaypause);
		
		// Keyboard Navigate
		carousel.addEventListener('keydown', keyHandler);		
		function keyHandler(e) {
			// Left Arrow
			if (e.keyCode === 37 || (e.ctrlKey && e.keyCode === 37)) {		
				e.preventDefault();
				slideprev();
			}
			// Right Arrow
			if (e.keyCode === 39 || (e.ctrlKey && e.keyCode === 39)) {		
				e.preventDefault();
				slidenext();
			}
			// Space
			if (e.keyCode === 32) {		
				e.preventDefault();
				slideplaypause();
			}
	    }
	        
		// Animate Slides
		playpause = setInterval(slidenext, timeInterval);
		
		// Stop Carousel on keyboard/mouse focus only when Carousel auto-rotating
		function slidefocusstop() {
			if(!carousel.classList.contains('btnpressed')){
				slidepause();
			}
		}
		function slidefocusplay() {
			if(!carousel.classList.contains('btnpressed')){
				slideplay();
			}
		}			
		carousel.addEventListener('focusin', slidefocusstop);	
		carousel.addEventListener('focusout', slidefocusplay);
		carousel.addEventListener('mouseover', slidefocusstop);
		carousel.addEventListener('mouseout', slidefocusplay);
				
	} else { // End itemscount > 1 => Remove buttons controls
		btnprev.parentNode.removeChild(btnprev);
		btnnext.parentNode.removeChild(btnnext);
		btnplaypause.parentNode.removeChild(btnplaypause);
	}
	
} // End if test