On connait tous ces boutons "Voir plus" ou "Afficher tout" qui cachent une partie des filtres dans les barres latérales.
C'est pratique pour la grande majorité des utilisateurs, ça allège visuellement la page, et tout le monde est content. Enfin… en théorie.
Essayez de naviguer au clavier pour atteindre la liste de produits au coeur de la page. Pour peu qu'il n'y ait pas de landmark sur cette fameuse liste, votre client va devoir parcourir une liste interminable d'éléments, voir même ne pas savoir qu'il y en a des plus ou moins cachés.
Et hop ! une conversion en moins !
Les problèmes principaux
La construction HTML approximative, focus accessibilité.
- Navigation clavier dégradée.
- Trop d’éléments interactifs avant le contenu principal
- Ordre de tabulation long, imprévisible
- Focus piégé dans des groupes repliables mal gérés
- Structure sémantique déficiente
- Groupes non balisés (
divau lieu defieldset/legend) - Hiérarchie de titres incohérente quand utilisée
- Rôles ARIA redondants ou incorrects
- Groupes non balisés (
- Annonces lecteurs d’écran insuffisantes
- Changements de résultats non annoncés
- États des filtres actifs mal exposés
- Contenus dynamiques mal gérés
aria-expandedmanquant ou faux- Contenus masqués toujours focusables
- Focus non repositionné après mise à jour
- Et encore bien d'autres…
Exemple des critères WCAG touchés (liste de liens en anglais) :
- 1.3.1 Info and Relationships
- 2.1.1 Keyboard
- 2.4.3 Focus Order
- 2.4.7 Focus Visible
- 3.2.2 On Input
- 4.1.2 Name, Role, Value
- 4.1.3 Status Messages
Le module proposé ici est une implémentation Vanilla qui tente de gérer ces détails proprement, avec une attention particulière à l'accessibilité.
Il permet d'automatiser une grande partie de l'accessibilité nécessaire à rendre utilisable ce type de composant.
C'est une base de travail, en aucun un plugin qui fait le café, la vaisselle et grille-pain en même temps qu'il vous masse les pieds.
Les filtres interminables
Vous la voyez cette fameuse barre latérale pour affiner la recherche, hein ?
Vous avez une soudaine envie d'acheter un nouveau réfrigérateur ? Rendez-vous sur ce site (ce n'est qu'un exemple parmi tant d'autres) et naviguez au clavier en partant de la barre latérale à côté de la liste des produits suggérés. Bon courage…
Alors, que de manière assez simple, on peut avoir quelque chose d'utilisable, un peu comme ça.
Cet exemple ne s'attache qu'à la navigation clavier et à la compatibilité avec les lecteurs d'écran.
Visuellement, ça ne change pas grand chose.
La base de code
Un attribut data-toggle-list et c'est parti
<ul data-toggle-list>
<li>Item 1</li>
<li>Item 2</li>
<!-- ... -->
</ul>
L'approche est minimaliste : on pose l'attribut data-toggle-list avec le nombre d'items à afficher initialement (ou rien, par défaut c'est 3), et le script s'occupe du reste.
Note : ça fonctionne aussi sur des listes de définitions (<dl> avec <dt> et <dd>). On ne se limite pas aux <ul> et <ol>.
Attributs data disponibles :
data-toggle-list: Obligatoire : nombre d'items à afficher initialement (number ou vide)data-toggle-open: Texte du bouton pour afficher plus (optionnel, défaut: "Voir plus")data-toggle-close: Texte du bouton pour masquer (optionnel, défaut: "Voir moins")data-toggle-position: Par défaut, le bouton est placé après la liste. data-toggle-position="before" le place avant.
Note : l'internationalisation est prise en compte (voir la documentation dans le JavaScript).
Le moteur
Injection CSS dynamique et variables custom.
Plutôt que de manipuler directement les styles inline, le module injecte un élément <style> unique par liste :
<style id="style-toggle-list-mxfj3kkx2">
#list-mxfj3kkx2 {
height: var(--height-closed, auto);
overflow: hidden;
transition: height .2s cubic-bezier(0.3, 1, .3, 1);
& > *:nth-child(n+4) {
visibility: hidden;
pointer-events: none;
transition: visibility 0s .1s;
}
&.is-expanded {
overflow: visible;
& > *:nth-child(n+4) {
visibility: visible;
pointer-events: auto;
transition: visibility 0s;
}
}
}
</style>
Quelques choix techniques :
visibility: hiddenplutôt quedisplay: none: les éléments gardent leur espace dans le DOM, ce qui limite les recalculs de layoutpointer-events: none: empêche les interactions sur les éléments cachés- Transition différée sur
visibility: un petit délai quand on referme, pour laisser l'animation de hauteur se terminer proprement - Variable CSS
--height-closed: calculée dynamiquement, permet de gérer la hauteur sans valeurs en dur. Toutes les valeurs depaddingetmarginsur les éléments de liste sont prisent en compte.
Listes de définitions : les groupes dt/dd
Une <dl> ne fonctionne pas comme une <ul> ou <ol>. On a des <dt> (termes) et des <dd> (définitions), et un terme peut avoir plusieurs définitions :
<dl data-toggle-list="2">
<dt>Terme 1</dt>
<dd>Définition 1</dd>
<dt>Terme 2</dt>
<dd>Définition 2a</dd>
<dd>Définition 2b</dd>
<dt>Terme 3</dt>
<dd>Définition 3</dd>
</dl>
Le module détecte automatiquement le type de liste et adapte son comportement. Pour les <dl>, il crée des groupes logiques en associant chaque <dt> avec tous les <dd> qui le suivent jusqu'au prochain <dt>.
Concrètement : si on demande d'afficher 2 éléments, le script affiche 2 groupes complets (terme + toutes ses définitions), pas 2 balises au hasard.
L'accessibilité
aria-hidden + inert, deux attributs complémentaires.
Masquer visuellement les éléments ne suffit pas. Il faut aussi les masquer pour les technologies d'assistance et empêcher la navigation clavier vers eux.
if (idx >= displayCount) {
item.setAttribute('aria-hidden', 'true');
item.inert = true;
}
aria-hidden="true" indique aux lecteurs d'écran d'ignorer l'élément.
inert rend l'élément complètement inactif : pas de focus, pas de clic, pas de sélection.
Quand on déplie la liste, ces attributs sont retirés. Les éléments redeviennent accessibles et focusables.
La gestion du focus
Où doit aller l'attention de l'utilisateur ?
Quand on clique sur "Voir plus", le focus est automatiquement placé sur le premier élément révélé. Mais comment faire si cet élément n'est pas naturellement focusable, comme un <li> ou un <dt> ?
firstHidden?.setAttribute('tabindex', '-1');
firstHidden?.focus();
firstHidden?.addEventListener('blur', () =>
firstHidden.removeAttribute('tabindex'),
{once: true}
);
L'astuce : on pose temporairement un tabindex="-1" pour permettre le focus programmatique, puis on le retire dès que l'élément perd le focus. Ça évite de créer des points de tabulation inutiles dans la navigation clavier.
La touche Escape : une sortie rapide
Quand on navigue au clavier dans la liste, on peut revenir au bouton de contrôle en appuyant sur Escape. C'est un pattern qu'on retrouve dans les menus déroulants, les modales, et autres composants interactifs.
Le script
Le code source contient la totalité des informations nécessaires à son utilisation.
Taille brute : 12,10 kilo-octets (~3,50 kilo-octets gzip)
/**
* Module de gestion des listes extensibles (Show more).
* Affiche un nombre limité d'items avec un bouton pour révéler le reste.
* Gère automatiquement les hauteurs, animations et accessibilité.
* Support : ul/ol (li) et dl (dt/dd).
*
* @module ToggledList
* @requires Utils.skipLinks (optionnel) - Pour la gestion des liens de navigation
*
* @example
* // Liste simple avec ul/ol
* <ul data-toggle-list="5" data-toggle-open="Afficher tout" data-toggle-close="Masquer">
* <li>Item 1</li>
* <li>Item 2</li>
* <li>Item 3</li>
* <!-- ... -->
* </ul>
*
* @example
* // Liste de définitions avec dl
* <dl data-toggle-list="3">
* <dt>Terme 1</dt>
* <dd>Définition 1</dd>
* <dt>Terme 2</dt>
* <dd>Définition 2a</dd>
* <dd>Définition 2b</dd>
* <!-- ... -->
* </dl>
*
* @description
* Attributs data disponibles :
* - data-toggle-list : Nombre d'items à afficher initialement (number ou vide)
* - data-toggle-open : Texte du bouton pour afficher plus (optionnel, défaut: "Voir plus")
* - data-toggle-close : Texte du bouton pour masquer (optionnel, défaut: "Voir moins")
* - data-toggle-position : Par défaut, le bouton est placé après la liste. data-toggle-position="before" le place avant.
*
* Classes CSS générées :
* - .toggled-list-wrapper : Wrapper autour de la liste pour la customisation projet
* - .is-expanded : Classe ajoutée à la liste lorsqu'elle est étendue
* - .link-color.underline : Classes appliquées au bouton de toggle, à adapter.
*
* Variables CSS :
* - --height-closed : Hauteur de la liste lorsqu'elle est fermée
*
* Custom Events :
* - togglelist:before : Déclenché avant l'expansion/réduction (cancelable)
* - togglelist:after : Déclenché après l'expansion/réduction complète
*
* @example
* // Écouter les événements
* document.addEventListener('togglelist:before', (e) => {
* console.log('Avant:', e.detail.willExpand ? 'ouverture' : 'fermeture');
* // e.preventDefault(); // pour annuler l'action
* });
*
* document.addEventListener('togglelist:after', (e) => {
* console.log('Après:', e.detail.isExpanded ? 'ouvert' : 'fermé');
* });
*/
(() => {
'use strict';
/**
* Utilitaire wrapper()
* Enveloppe un élément HTML dans un conteneur parent.
*/
const wrapper = (el, tag = 'div', attrs = {}) => {
const wrapper = document.createElement(tag);
Object.entries(attrs).forEach(([key, val]) =>
key === 'className' ? wrapper.className = val : wrapper.setAttribute(key, val)
);
el.parentNode.insertBefore(wrapper, el);
wrapper.appendChild(el);
return wrapper;
};
/**
* Initialise les listes extensibles dans le contexte donné.
*/
window.initToggleList = (context = document.body) => {
// Vérification de l'objet i18n et de son contenu
window.i18n ??= {};
window.i18n.toggle_list_skip_label ??= "Passer le groupe ";
window.i18n.toggle_list_skip_after ??= "Remonter le groupe ";
window.i18n.toggle_list_open ??= "Voir plus ";
window.i18n.toggle_list_close ??= "Voir moins";
window.i18n.toggle_list_escape_label ??= "Touche Échappement pour sortir de la liste ";
context.querySelectorAll('[data-toggle-list]:not([data-toggle-init])').forEach(list => {
list.id = list.id || `list-${Math.random().toString(36).slice(2, 11)}`;
list.setAttribute('data-toggle-init', 'true');
const skipLabel = list.dataset.toggleName || '';
wrapper(list, 'div', {
className: 'toggled-list-wrapper',
'data-skip': `{"label": "${window.i18n.toggle_list_skip_label} ${skipLabel}", "after": "${window.i18n.toggle_list_skip_after} ${skipLabel}"}`
});
const displayCount = parseInt(list.dataset.toggleList, 10) || 3;
const isDl = list.tagName === 'DL';
let items;
let groups;
if (isDl) {
const allElements = [...list.children].filter(el => el.nodeType === 1);
groups = [];
let currentGroup = [];
allElements.forEach(el => {
if (el.tagName === 'DT') {
currentGroup.length && groups.push(currentGroup);
currentGroup = [el];
} else if (el.tagName === 'DD') {
currentGroup.push(el);
}
});
currentGroup.length && groups.push(currentGroup);
items = allElements;
} else {
items = [...list.children].filter(el => el.nodeType === 1);
}
const totalCount = isDl ? groups.length : items.length;
if (totalCount <= displayCount) return;
list.setAttribute('aria-label', window.i18n.toggle_list_escape_label);
list.addEventListener('keyup', (e) => {
if (e.key === 'Escape') {
btn.focus();
}
});
// CONSTRUCTION DU SÉLECTEUR - FIX ICI
let hiddenSelector;
if (isDl) {
const dtSelectors = [];
groups.forEach((group, idx) => {
if (idx >= displayCount) {
group.forEach(el => {
const position = [...list.children].indexOf(el) + 1;
// FIX: Retire #${list.id} > du sélecteur
dtSelectors.push(`*:nth-child(${position})`);
});
}
});
hiddenSelector = dtSelectors.join(', ');
} else {
// FIX: Retire le > initial
hiddenSelector = `*:nth-child(n+${displayCount + 1})`;
}
// INJECTION CSS - FIX ICI
!document.getElementById(`style-toggle-${list.id}`) && document.head.appendChild(Object.assign(
document.createElement('style'), {
id: `style-toggle-${list.id}`,
textContent: `
#${list.id} {
height: var(--height-closed, auto);
overflow: hidden;
& > ${hiddenSelector} {
visibility: hidden;
pointer-events: none;
position: absolute;
left: -9999px;
}
}
#${list.id}.is-expanded {
overflow: visible;
& > ${hiddenSelector} {
visibility: visible;
pointer-events: auto;
position: static;
}
}`
}
));
const updateHeights = () => {
let lastVisible;
if (isDl) {
const lastGroup = groups[displayCount - 1];
lastVisible = lastGroup?.[lastGroup.length - 1];
} else {
lastVisible = items[displayCount - 1];
}
if (!lastVisible) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const closedHeight = `${lastVisible.offsetTop + lastVisible.offsetHeight - list.offsetTop}px`;
list.style.setProperty('--height-closed', closedHeight);
// Force la hauteur inline si la liste n'est pas expanded
if (!list.classList.contains('is-expanded')) {
list.style.height = closedHeight;
} else {
list.style.height = `${list.scrollHeight}px`;
}
});
});
};
const initHeights = () => {
document.fonts.ready.then(() => {
requestAnimationFrame(() => requestAnimationFrame(updateHeights));
});
};
initHeights();
new ResizeObserver(() => requestAnimationFrame(updateHeights)).observe(list);
const btn = Object.assign(document.createElement('button'), {
type: 'button',
className: 'link-color underline'
});
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-controls', list.id);
const updateButton = (isExpanded) => {
btn.textContent = isExpanded
? (list.dataset.toggleClose || window.i18n.toggle_list_close)
: `${list.dataset.toggleOpen || window.i18n.toggle_list_open} (+${totalCount - displayCount})`;
btn.setAttribute('aria-expanded', String(isExpanded));
};
updateButton(false);
if (isDl) {
groups.forEach((group, idx) => {
if (idx >= displayCount) {
group.forEach(el => {
el.setAttribute('aria-hidden', 'true');
el.inert = true;
});
}
});
} else {
items.forEach((item, idx) => {
if (idx >= displayCount) {
item.setAttribute('aria-hidden', 'true');
item.inert = true;
}
});
}
btn.addEventListener('click', () => {
const willExpand = !list.classList.contains('is-expanded');
const hiddenItems = isDl
? groups.slice(displayCount).flat()
: items.slice(displayCount);
const beforeEvent = new CustomEvent('togglelist:before', {
bubbles: true,
cancelable: true,
detail: {
list: list,
listId: list.id,
willExpand: willExpand,
button: btn,
displayCount: displayCount,
totalCount: totalCount,
hiddenItems: hiddenItems,
isDl: isDl
}
});
if (!list.dispatchEvent(beforeEvent)) {
return;
}
list.classList.toggle('is-expanded');
// Met à jour la hauteur inline
if (willExpand) {
list.style.height = `${list.scrollHeight}px`;
} else {
list.style.height = getComputedStyle(list).getPropertyValue('--height-closed');
}
updateButton(willExpand);
if (isDl) {
groups.forEach((group, idx) => {
if (idx >= displayCount) {
group.forEach(el => {
willExpand
? el.removeAttribute('aria-hidden')
: el.setAttribute('aria-hidden', 'true');
el.inert = !willExpand;
});
}
});
} else {
items.forEach((item, idx) => {
if (idx >= displayCount) {
willExpand
? item.removeAttribute('aria-hidden')
: item.setAttribute('aria-hidden', 'true');
item.inert = !willExpand;
}
});
}
if (willExpand) {
let firstHidden;
if (isDl) {
firstHidden = groups[displayCount]?.[0];
} else {
firstHidden = items[displayCount];
}
firstHidden?.setAttribute('tabindex', '-1');
firstHidden?.focus();
firstHidden?.addEventListener('blur', () => firstHidden.removeAttribute('tabindex'), {once: true});
} else {
btn.focus();
}
const fireAfterEvent = () => {
list.dispatchEvent(new CustomEvent('togglelist:after', {
bubbles: true,
detail: {
list: list,
listId: list.id,
isExpanded: willExpand,
button: btn,
displayCount: displayCount,
totalCount: totalCount,
visibleItems: isDl ? groups.slice(0, willExpand ? totalCount : displayCount).flat() : items.slice(0, willExpand ? totalCount : displayCount),
hiddenItems: willExpand ? [] : hiddenItems,
isDl: isDl
}
}));
};
requestAnimationFrame(() => requestAnimationFrame(fireAfterEvent));
});
const position = ['before', 'after'].includes(list.dataset.togglePosition)
&& list.dataset.togglePosition
|| 'after';
position === 'before'
? list.insertAdjacentElement('beforebegin', btn)
: list.insertAdjacentElement('afterend', btn);
wrapper(btn, 'div', {
className: 'text-align-center ' + (position === 'before' ? 'mt-h' : 'mb-h')
});
});
};
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', () => window.initToggleList())
: window.initToggleList();
})();
Télécharger kortic-toggled-list.js
Bonus
Un petit custom event quand la liste s'ouvre/ferme, pour permettre à d'autres scripts de réagir, analytics , synchro avec d'autres composants, etc. C'est cadeau.
togglelist:before: Déclenché avant l'expansion/réduction (cancelable)togglelist:after: Déclenché après l'expansion/réduction complète
That's all, Folks !