Utiliser le pattern ARIA carousel en 2026 reste overkill pour la majorité des cas d'usage, le scroll natif et la sémantique HTML font le travail sans la dette technique. CSS Overflow 5 et ::scroll-marker promettent une alternative native élégante, mais le support est encore trop fragmenté pour une utilisation en production sereine. À surveiller de près pour 2027.
En attendant, voilà ce qu'on peut faire.
Un carrousel est, du point de vue des technologies d'assistance, une liste de contenu défilable. Les problèmes classiques sont nombreux : focus piégé dans les clones, boutons sans label lisible, scroll non annoncé, contenu dupliqué exposé aux lecteurs d'écran. SimCa adresse ces points structurellement, au niveau du markup et du comportement.
Pourquoi SimCa et pas le pattern APG « officiel » ? Parce que la théorie c'est bien, mais dans un environnement de CMS compliqué, c'est quasi impossible à mettre en œuvre correctement.
Le choix délibéré du scroll CSS et de la sémantique native plutôt que d'une couche ARIA qui génère plus de problèmes qu'elle n'en résout.
Responsive
… oui, mais sans breakpoint navigateur.
La résolution responsive de SimCa est basée sur la largeur du container, pas sur la largeur du viewport. C'est la distinction fondamentale avec une media query classique.
Une media query @media (min-width: 37.5rem) (600px) se déclenche selon la fenêtre du navigateur. Si le carrousel est dans une sidebar de 300 px sur un écran de 1400 px, la media query à 600 px est active, mais le carrousel n'a que 300 px. Le calcul du nombre d'items visibles et du peek sera faux.
Avec resolveByWidth(cfg, containerWidth), les seuils définis dans data-properties sont comparés à simca.offsetWidth (puis mis à jour par ResizeObserver). Le carrousel affiche le bon nombre d'items quelle que soit sa position dans la mise en page : pleine largeur, colonne étroite, widget embarqué dans un <aside>.
Les custom properties --simca-item-width, --simca-gap et --simca-peek sont recalculées en pixels réels à chaque changement de largeur du container, pas en % ou rem relatifs au viewport. Cela garantit un rendu pixel-perfect indépendant du zoom navigateur et des arrondis CSS.
Point non négligeable : quel que soit le niveau et le type (vectoriel ou texte seulement) de zoom navigateur, les listes s'adaptent à la configuration imposée par les data-properties.
Markup
Base
Structure minimale fonctionnelle, sans aucune option configurée. Un item visible, boutons centrés verticalement sur les côtés par défaut, pas de boucle, pas d'autoplay.
<div class="simca">
<ul class="simca-list">
<li class="simca-list-item">…</li>
<li class="simca-list-item">…</li>
<li class="simca-list-item">…</li>
</ul>
</div>
Sans data-properties les valeurs tombent sur leurs défauts : gap: 0, items: 1, loop: false, pas de compteur, pas d'autoplay, seulement les boutons de navigation.
Avec options
<div
class="simca" id="carousel-home" data-properties='{
"gap": 1.5,
"peek": { "default": 1, "600": 1.5, "900": 2 },
"items": { "default": 1, "600": 2, "900": 3 },
"loop": true,
"buttons": { "v": "bottom", "h": "right" },
"counter": { "default": false, "600": true },
"autoplay": { "delay": 5000 }
}'>
<ul class="simca-list">
<li class="simca-list-item">…</li>
<li class="simca-list-item">…</li>
<li class="simca-list-item">…</li>
</ul>
</div>
Référence data-properties
L'attribut qui va permettre de gérer le composant directement.
gap, number | object
Espacement entre les items, exprimé en rem. Défaut : 0.
Valeur fixe :
"gap": 1.5
Valeur responsive (seuils en px, mobile-first) :
"gap": { "default": 0, "600": 1, "900": 1.5 }
peek, number | object
Fait dépasser visuellement le bord de l'item adjacent pour signaler qu'il y a du contenu hors-écran. La valeur est un multiplicateur du gap : peek: 2 avec gap: 1.5rem produit un débordement de 3rem. Défaut : 1.
Valeur fixe :
"peek": 2
Valeur responsive (seuils en px, mobile-first) :
"peek": { "default": 1, "600": 1.5, "900": 2 }
"default": valeur appliquée en dessous du premier seuil.- Les clés numériques sont des seuils de largeur du container (pas du viewport).
- Le seuil actif est le plus grand seuil ≤ largeur courante du container.
Note accessibilité : le peek est un signal visuel de scrollabilité. Sans lui, rien n'indique à l'utilisateur voyant qu'il y a plus de contenu. C'est l'équivalent visuel de l'information que le lecteur d'écran obtient via le compteur et les labels de boutons.
En mode loop, le peek s'applique symétriquement des deux côtés (padding-inline). Sans loop, uniquement côté fin (padding-inline-end).
items, number | object
Nombre d'items visibles simultanément dans le viewport du carrousel. Défaut : 1.
Valeur fixe :
"items": 2
Valeur responsive, même logique de résolution que peek :
"items": { "default": 1, "600": 2, "900": 3 }
loop, boolean | object
Active le défilement en boucle infinie. Le script clone un buffer minimal d'items (juste assez pour remplir un viewport de chaque côté), pas la liste entière. Les clones sont neutralisés pour l'accessibilité (aria-hidden, tabindex="-1" sur les focusables). Défaut : false.
Valeur fixe :
"loop": true
Valeur responsive (désactiver la boucle sur mobile par exemple) :
"loop": { "default": false, "600": true }
Note : contrairement aux autres options, loop n'est résolu qu'à l'initialisation et n'est pas recalculé au resize. Un rechargement de page est nécessaire pour changer ce comportement.
buttons, object
Contrôle le positionnement des boutons prev/next.
"buttons": { "v": "bottom", "h": "right" }
v, position verticale du groupe de boutons :
| Valeur | Résultat |
|---|---|
| (absent) | Boutons absolus, centrés verticalement, un de chaque côté |
"top" |
Groupe de boutons au-dessus du carrousel |
"bottom" |
Groupe de boutons en dessous du carrousel |
h, alignement horizontal du groupe (uniquement si v est défini) :
| Valeur | Résultat |
|---|---|
"left" |
Groupe aligné à gauche |
"center" |
Groupe centré |
"right" |
Groupe aligné à droite (défaut si v est défini) |
"split" |
Boutons séparés aux deux extrémités (space-between) |
counter, boolean | object
Affiche un compteur de pagination "X sur Y". Même format responsive que peek et items. Défaut : false.
Valeur fixe :
"counter": true
Valeur responsive :
"counter": { "default": false, "600": true }
- Si
buttons.vest défini (topoubottom) : le compteur est injecté visuellement entre les deux boutons, dans.simca-buttons-wrapper. - Sinon : le compteur est injecté dans le DOM avec la classe
screen-readers, réservé aux lecteurs d'écran, invisible à l'écran.
Dans les deux cas : role="status" + aria-live="polite" + aria-atomic="true".
autoplay, boolean | object
Défilement automatique.
"autoplay": true
Valeurs personnalisées
"autoplay": { "delay": 5000, "direction": 1 }
| Propriété | Type | Défaut | Description |
|---|---|---|---|
delay |
number |
5000 |
Intervalle entre les défilements, en millisecondes |
direction |
1 | -1 |
1 |
Sens initial : 1 = droite, -1 = gauche |
Comportements automatiques liés à l'autoplay :
- Bouton play/pause injecté automatiquement (exigence WCAG 2.2.2). Jamais créé si
prefers-reduced-motion: reduceest actif ou si.no-motionest présent sur<html>. - Pause sur interaction : hover, focus dans la liste, touch. Reprise à la fin de l'interaction (sauf si l'utilisateur a explicitement mis en pause via le bouton).
- Pause sur onglet masqué :
visibilitychange. Reprise au retour sur l'onglet. - Pause si hors viewport :
IntersectionObserveravec un seuil de 10 %. Ne consomme pas de ressources pour les carrousels hors écran. - Persistance de l'état pause : si l'élément
.simcaa un attributid, l'état pause manuel est conservé enlocalStorageentre les sessions. - Sans loop : l'autoplay rebondit (
bounce), inverse la direction quand il atteint un bord.
spotlight, boolean | object
Active le mode spotlight : l'item actif est affiché à pleine taille, les items en peek sont réduits via scale CSS.
Ne fonctionne qu'avec items: 1, loop: true et une dimension de bloc égale ou supérieure à 600px.
grid, object
Bascule le carrousel en grille statique au-dessus d'un seuil de largeur de container. Plus de scroll, plus de boutons, plus de compteur, plus de boucle, plus d'autoplay : à partir du seuil, le composant redevient une grille CSS classique, point. Défaut : pas de grid, comportement carrousel inchangé sur toute la plage de largeurs.
Format court, nombre de colonnes par seuil :
"grid": { "1000": 3, "1400": 4 }
Format étendu, avec limite de lignes optionnelle :
"grid": { "1000": { "cols": 3, "rows": 2 }, "1400": { "cols": 4 } }
- Pas de clé
"default": en dessous du premier seuil, comportement carrousel scroll standard, exactement comme sigridn'existait pas. cols: nombre de colonnes de la grille à ce seuil.rows(optionnel) : limite le nombre de lignes affichées. Au-delà decols × rowsitems, les éléments excédentaires sont retirés du DOM (remove()), pas masqués en CSS. Sansrows, tous les items s'affichent.- Comme
loopetspotlight, résolu une seule fois à l'initialisation, jamais recalculé au resize. Un agrandissement de fenêtre en cours de session ne fait pas basculer un carrousel en grille a posteriori.
Pourquoi remove() et pas du display: none ou un attribut hidden posé en CSS ? Deux raisons :
- Focus clavier. Un item masqué uniquement en CSS reste dans l'ordre de tabulation : Tab continue d'y entrer alors qu'il est invisible à l'écran.
remove()retire l'élément du DOM, donc de l'arbre d'accessibilité et du focus, en une seule opération native. - Hauteur de bloc inconnue. Tronquer en CSS pur (
max-height+overflow: hidden) suppose de connaître à l'avance la hauteur d'une ligne, ce qui est hasardeux dès que le contenu des items varie (texte de longueur variable, images de ratios différents). Retirer les items parcols × rowsévite ce calcul.
Indexation SEO : le crawl de Google se fait en mobile-first. Sous le seuil grid, la page expose l'intégralité des items en carrousel scroll, c'est cette version qui est indexée. La troncature rows ne s'applique qu'au-dessus du seuil grid, donc uniquement à un gabarit que le crawl n'évalue pas en priorité. Aucune perte d'indexation côté contenu retiré en grand écran.
Exemple complet, carrousel mobile qui devient une grille de 3 colonnes sur 2 lignes maximum dès 1000px, puis 4 colonnes sans limite de lignes dès 1400px :
"grid": { "1000": { "cols": 3, "rows": 2 }, "1400": { "cols": 4 } }
Options classes CSS
Classes CSS optionnelles sur .simca
| Classe | Effet |
|---|---|
simca-hide-buttons |
Masque les boutons au repos, les révèle au hover/focus. Annulé si simca-persistent-buttons est sur <html>. |
simca-btn-medium |
Sur le bloc .simca ou un parent, boutons de navigation à 2.75rem (44px). |
simca-btn-large |
Sur le bloc .simca ou un parent, Boutons de navigation à 3.125rem (50px). |
simca-persistent-buttons sur <html> : force l'affichage permanent des boutons pour tous les carrousels de la page. Permet une bascule globale depuis un bouton d'accessibilité (utilisateurs qui ont besoin des boutons visibles en permanence).
Initialisation manuelle (injection AJAX)
Par défaut, Utils.simCa() est appelé sur document.body au chargement. Pour initialiser les carrousels injectés dynamiquement :
// Nœud parent du contenu injecté
Utils.simCa(document.querySelector('#nouveau-contenu'));
// Ou sélecteur CSS
Utils.simCa('#nouveau-contenu');
Les instances déjà initialisées (classe is-init) sont ignorées.
Internationalisation (i18n)
Le script embarque des valeurs par défaut en français qui peuvent être modifiées en amont dans un objet window.i18n avant le chargement du script. Par exemple :
window.i18n = {
skipBefore: 'Skip list of %d item%s',
skipAfter: 'Return before list of %d item%s',
labelLeft: 'Scroll list left',
labelLeftDisabled: 'Inactive button, list is at start',
labelRight: 'Scroll list right',
labelRightDisabled: 'Inactive button, list is at end',
labelEscape: 'Skip %d item%s with Escape key. Navigate with left and right arrow keys',
indexSeparator: ' of ',
plural: 's',
autoplayStop: 'Stop automatic scrolling',
autoplayStart: 'Start automatic scrolling',
};
Les tokens %d (valeur numérique) et %s (suffixe pluriel) sont remplacés dynamiquement.
Surcharge du sélecteur focusable
Utils.FOCUSABLE_SELECTORS peut être redéfini avant le chargement du script si l'environnement utilise des composants focusables non couverts par le sélecteur par défaut :
window.Utils = window.Utils || {};
Utils.FOCUSABLE_SELECTORS = 'a[href], button, [tabindex="0"], mon-composant-custom';
Côté accessibilité
Pourquoi pas de role="region" ni de pattern ARIA carousel ?
Le pattern ARIA carousel (tel que documenté par l'APG, ARIA Authoring Practices Guide) prescrit un conteneur role="region" avec aria-roledescription="carousel", des slides en role="group" avec aria-roledescription="slide", et une gestion de aria-live sur le conteneur actif. Ce pattern est délibérément écarté dans SimCa, pour plusieurs raisons.
aria-roledescription : un mot sans sémantique
aria-roledescription="carousel" remplace le nom du rôle annoncé par le lecteur d'écran par la chaîne littérale « carousel ». Ce mot ne correspond à aucun rôle ARIA natif, il ne transmet aucune information sur la structure, le nombre d'éléments, ni le comportement attendu. Un utilisateur de lecteur d'écran entend « carousel » sans savoir ce que cela implique en termes d'interaction. C'est une surcharge sémantique creuse.
À l'inverse, une <ul> annonce nativement « liste, N éléments ». L'information est immédiatement exploitable.
aria-live sur le conteneur : flood d'annonces
Le pattern APG suggère de mettre aria-live sur la région du carrousel pour annoncer les changements de slide. En pratique, sur un scroll fluide ou un autoplay, cela génère une annonce à chaque item qui passe dans le viewport, parfois plusieurs par seconde. Le résultat est une expérience dégradée, voire inutilisable, pour les utilisateurs de NVDA ou JAWS.
SimCa isole aria-live="polite" sur le seul compteur (role="status"), mis à jour uniquement à la fin du scroll (scrollend). Une seule annonce, au bon moment.
Le scroll natif comme interaction de référence
SimCa repose sur le scroll CSS natif (overflow-x: auto + scroll-snap-type). Du point de vue des technologies d'assistance, une liste scrollable est un paradigme connu : le lecteur d'écran lit les items dans l'ordre du DOM, l'utilisateur navigue avec Tab entre les éléments focusables, et les flèches clavier sont réservées à l'exploration linéaire du contenu. Rien de nouveau à apprendre.
Le pattern APG introduit une gestion de focus active (déplacer le focus programmatiquement vers le slide actif), des touches fléchées détournées pour changer de slide, et un modèle d'interaction qui diverge du comportement natif attendu sur une liste. Cette complexité ajoutée n'apporte pas de valeur pour l'utilisateur, et introduit des risques de conflits avec les modes de navigation des lecteurs d'écran (mode lecture vs mode application).
Le pattern APG carousel est souvent critiqué dans la communauté accessibilité. Sara Soueidan, Adrian Roselli et d'autres ont documenté ses limites.
Du carrousel à la grille
Cette absence de surcouche ARIA n'est pas qu'un choix défensif, elle a une conséquence pratique directe sur l'option grid.
Un carrousel qui implémente le pattern APG a posé aria-roledescription="carousel" sur son conteneur, des role="group" avec aria-roledescription="slide" sur chaque item. Cette sémantique annonce explicitement à l'utilisateur de lecteur d'écran qu'il navigue dans un carrousel, avec des slides. Le jour où ce même composant doit s'afficher en grille statique sur un écran large, parce que scroller horizontalement pour voir huit éléments côte à côte sur 1400px de large n'a aucun sens, cette sémantique devient fausse. Le lecteur d'écran continue d'annoncer « carousel » et « slide » sur un contenu qui n'a plus rien d'un carrousel : pas de scroll, pas de navigation séquentielle, tout est visible en même temps. C'est exactement ce que font la plupart des librairies de carrousel du marché quand on leur demande un mode grille responsive : elles gardent l'attirail ARIA du mode scroll, peu importe le rendu visuel réel. Le résultat est une annonce trompeuse, qui ne correspond à aucune réalité d'interaction.
SimCa n'a pas ce problème, par construction. Le conteneur est une <ul>, les items sont des <li>. Aucune sémantique de rôle n'a jamais prétendu qu'il s'agissait d'un carrousel, c'est une liste, qu'elle défile horizontalement ou qu'elle s'affiche en grille. Quand l'option grid bascule l'affichage, rien ne change côté sémantique parce qu'il n'y a rien à défaire : pas de role="group" à retirer, pas de aria-roledescription à neutraliser, pas de gestion de focus active à débrancher. Le JS retire simplement les boutons, le compteur, les liens d'évitement et les listeners qui n'ont plus de sens sans scroll, et la liste continue d'être annoncée pour ce qu'elle est réellement : une liste de N éléments.
C'est un argument de fond pour préférer la sémantique native à une couche ARIA descriptive : une structure qui ne ment jamais sur ce qu'elle est s'adapte sans friction à un changement de présentation visuelle. Une structure qui annonce un rôle figé (« carousel », « slide ») doit être démontée et reconstruite à chaque changement de mode, avec le risque réel d'oublier une annonce devenue obsolète.
Choix d'implémentation accessibilité
Structure sémantique
Le conteneur de scroll est une <ul> / <ol> (ou tout élément de liste). Cela expose nativement le nombre d'éléments aux lecteurs d'écran ("liste, 8 éléments"), sans recourir à role="list" de rattrapage.
Navigation au clavier
Deux mécanismes coexistent :
- Liens d'évitement (
skip-list-first/skip-list-last) : permettent de sauter la liste entière sans traverser chaque item. Visibles uniquement au focus (:focus-within), positionnés hors-écran autrement. - Touche
Escape: depuis n'importe quel point dans le carrousel, envoie le focus sur le lien de sortie. Évite d'avoir à tabuler à travers tous les items pour sortir. - Flèches
←/→: scrollent d'un viewport. Le scroll natif gère le reste ; le script ne court-circuite pas le comportement par défaut du navigateur.
Boutons prev/next
Les boutons utilisent aria-disabled (et non disabled). Un bouton disabled disparaît de l'ordre de tabulation et prive l'utilisateur de l'information sur l'état de la navigation. Avec aria-disabled="true", le bouton reste focusable et son label décrit explicitement pourquoi il est inactif ("Bouton inactif, la liste est au début").
Clones et boucle infinie
En mode loop, les éléments clonés reçoivent aria-hidden="true" et tous leurs descendants focusables sont mis à tabindex="-1". Ils sont ainsi totalement transparents pour les lecteurs d'écran et le clavier, tout en assurant la continuité visuelle du scroll.
Compteur de pagination
Implémenté avec role="status" + aria-live="polite" + aria-atomic="true". L'annonce se fait après la fin du scroll, pas à chaque pixel, ce qui évite le flood de mises à jour vers les lecteurs d'écran.
Autoplay
Toute animation automatique doit respecter prefers-reduced-motion: reduce. SimCa va plus loin : il supporte une classe .no-motion sur <html> pour une bascule dynamique (bouton d'accessibilité côté interface), sans rechargement de page. Un bouton play/pause est systématiquement injecté si l'autoplay est actif, il n'est pas optionnel, c'est une exigence WCAG 2.1 (critère 2.2.2).
En résumé
SimCa fait le choix de ne pas implémenter le pattern ARIA carousel officiellement recommandé, parce que ce pattern génère plus de problèmes d'accessibilité qu'il n'en résout. À la place, il s'appuie sur ce que HTML et CSS savent déjà faire : une liste, un scroll natif, et des éléments interactifs correctement étiquetés.
Le responsive par container width plutôt que par viewport découle de la même logique : un composant doit fonctionner correctement quel que soit le contexte dans lequel il est intégré, sans hypothèse sur la relation entre sa taille et celle de la fenêtre.
Ces deux partis pris, sémantique native, isolation du contexte, sont les fils conducteurs de l'ensemble des choix d'implémentation. L'option grid en est une conséquence directe : un composant qui n'a jamais menti sur sa nature sémantique peut changer de présentation visuelle sans jamais avoir à se dédire.
Exemples
SimCa est utilisé un peu partout sur Kortic, notamment sur la page d'accueil en plusieurs exemplaires. Mais voici quelques variations possibles.
Basique : 1 item visible, sans loop, sans boutons, sans compteur visible (mais présent pour les lecteurs d'écran).
Usage minimal, défilement tactile/scroll uniquement et pas de data-properties.
Le compteur de vues est présent pour les technologies d'assistance, mais invisible.
Avec <div class="simca" data-properties='{"bullets": true}'>, des bullets inertes sont présentes et masquées aux lecteurs d'écran.
Boutons split + compteur visible, sans loop, 1 item
Boutons de part et d'autre de la liste (split), compteur entre les boutons et bouton simca-btn-medium.
data-properties='{
"items": 1,
"buttons": { "v": "bottom", "h": "split" },
"counter": true
}'
Boutons center, sans loop, 2 items fixes et bouton simca-btn-small.
data-properties='{
"items": 2,
"buttons": {"v": "bottom", "h": "center"},
"counter": true
}'
Responsive items + peek, boutons top/right, sans loop
1 item sur mobile, 2 à partir de 600px.
peek fait dépasser légèrement l'item suivant pour signaler qu'il y a plus de contenu.
data-properties='{
"gap": 1,
"peek": {"default": 2, "600": 4},
"items": {"default": 1, "600": 2},
"buttons": {"v": "top", "h": "right"}
}'
Loop infinie, boutons top/split, compteur visible
loop: true active le clonage de buffer et le repositionnement silencieux.
Dégradé gradient proportionnel à peek, basé sur la couleur de fond de page ou blanc/noir si la propriété CSS n'existe pas.
data-properties='{
"gap": 0.5,
"peek": 8,
"items": 2,
"loop": true,
"shadow": true,
"buttons": { "v": "top", "h": "split" },
"counter": true
}'
Option spotlight
data-properties='{
"gap": {"default": 0, "650": 2, "1300": 3},
"peek": {"default": 2, "650": 3, "1024": 5, "1400": 6},
"buttons": { "default": { "v": "bottom", "h": "center" }, "768": null },
"counter": {"default": true, "1400": false},
"loop": true,
"spotlight": { "default": false, "650": true }
}'
Un petit outil pour générer l'attribut data-properties ou le code HTML nécessaire. C'est cadeau !
Téléchargements
Javascript
C'est du Vanilla… Le fichier contient toutes les explications en commentaires.
Taille brute : 57,58 kilo-octets (~16,18 kilo-octets gzip)
/**
* simCa (1000 lignes… <500 sans commentaires). Simple Carousel v2
*
* Carrousel accessible et responsive basé sur le scroll natif + scroll-snap CSS.
* La boucle infinie est obtenue par clonage d'un buffer minimal d'éléments (pas de
* duplication complète de la liste), ce qui limite l'impact sur le DOM.
* Chaque instance est configurée via l'attribut `data-properties` (JSON).
* Le script s'auto-initialise au chargement sur tous les `.simca` présents dans le DOM,
* et expose `Utils.simCa(root?)` pour une initialisation manuelle (ex. après injection AJAX).
* Mode statique automatique : si tous les items tiennent dans la vue (nombre d'items <= items
* visibles), la liste n'est pas défilante : ni boutons, compteur, bullets, liens d'évitement,
* autoplay ni clones. Réévalué au resize (items est responsive). Classe posée : .simca-static.
* @example
* <div class="simca" data-properties='{
* "gap": 1.5,
* "peek": { "default": 1, "600": 1.5, "900": 2 },
* "items": { "default": 1, "600": 2, "900": 3 },
* "loop": true,
* "buttons": { "v": "bottom", "h": "right" },
* "counter": true,
* "autoplay": { "delay": 5000, "direction": 1 },
* "spotlight": { "default": false, "600": true },
* "grid": { "1000": { "cols": 3, "rows": 2 }, "1400": { "cols": 4 } }
* }'>
* <ul class="simca-list">
* <li class="simca-list-item">…</li>
* </ul>
* </div>
* Options disponibles dans data-properties :
* gap {number} Espacement entre items en rem. Défaut : 0.
* peek {number|Object} Multiplicateur du gap pour faire dépasser l'item suivant
* et signaler qu'il y a plus de contenu. Responsive possible :
* { "default": 1, "600": 1.5 } = mobile-first, seuils en px.
* items {number|Object} Nombre d'items visibles simultanément. Même format responsive.
* offset {boolean|Object} Marge permanente à gauche et à droite, alignée automatiquement sur le
* contenu de la page (sans loop uniquement, ignoré sinon). Mesurée à gauche
* sur le premier ancêtre plus étroit que le carrousel, reprise en miroir à
* droite. Chaque page s'arrête sur la marge de gauche, la dernière slide sur
* celle de droite ; les slides voisines restent visibles dans la marge. Même
* format responsive ; sans "default", l'option est inactive sous le premier seuil.
* Ex. { "default": false, "1000": true }
* loop {boolean} Active le défilement en boucle infinie via clones. Défaut : false.
* buttons {Object} Position des boutons prev/next.
* v : "top" | "bottom" (axe vertical du wrapper).
* h : "left" | "center" | "right" | "split" (axe horizontal).
* counter {boolean|Object} Affiche un compteur "page X sur Y". Même format responsive que
* items/peek. Visible si buttons.v est défini, sinon réservé aux
* lecteurs d'écran. Défaut : false.
* autoplay {Object} Défilement automatique.
* delay {number} Intervalle en ms. Défaut : 5000.
* direction {1|-1} Sens initial (1 = droite). Défaut : 1.
* L'autoplay se met en pause sur hover/focus/touch et respecte
* prefers-reduced-motion. Un bouton play/pause est injecté
* automatiquement. L'état manuel (pause volontaire) est persisté
* en localStorage si l'élément a un id.
* spotlight {boolean|Object} Active le mode spotlight : l'item actif est affiché à pleine
* taille, les items en peek sont réduits via scale CSS.
* Requiert loop:true et items:1. Même format responsive que
* items/peek — permet de désactiver le mode en-dessous d'un seuil
* (ex. { "default": false, "600": true }).
* La classe .simca-spotlight est posée automatiquement par le JS
* quand le mode est actif ; ne pas la poser manuellement en HTML.
* shadow {boolean|Object} Active les dégradés gauche/droite
* bullets {boolean|Object} Puces de pagination 100% décoratives (aria-hidden, aucun
* listener de clic). Même format responsive que items/peek.
* Forcées à false en mode spotlight, quelle que soit la config.
* grid {Object} Affichage statique en grille au-dessus d'un seuil, plus de
* carrousel (pas de scroll/boutons/loop/autoplay/bullets/compteur).
* Format { "seuil": colonnes|{cols, rows}, … }, sans "default" : en
* dessous du premier seuil, comportement carrousel scroll classique.
* rows est optionnel : si précisé, limite le nombre de lignes
* affichées (cols × rows items max), les items excédentaires sont
* retirés du DOM (remove()). Sans rows, tous les items s'affichent.
* Résolu une seule fois à l'init, pas de bascule au resize.
* Ex. { "1000": { "cols": 3, "rows": 2 }, "1400": { "cols": 4 } }
* ou, format court sans limite de lignes : { "1000": 3, "1400": 4 }.
* skip {boolean} (forme objet uniquement) : force la pose des liens
* d'évitement avant/après la liste en mode grille, utile pour une
* grille longue ou paginée. Le libellé tient compte des items
* retirés/masqués par rows. Défaut : false.
* Ex. { "1000": { "cols": 3, "skip": true } }
*/
(() => {
'use strict';
/**
* Classe utilitaire pour les éléments réservés aux lecteurs d'écran.
* À adapter à l'environnement (sr-only, visually-hidden…).
* @type {string}
*/
const ATClass = 'screen-readers';
window.Utils = window.Utils || {};
/**
* Sélecteur CSS ciblant tous les éléments nativement focusables.
* Utilisé pour rendre les clones inaccessibles au clavier (tabindex="-1")
* et pour détecter si `.simca-list` doit recevoir un tabindex=0 lui-même.
* Peut être surchargé avant le chargement du script : window.Utils.FOCUSABLE_SELECTORS = '…'
* @type {string}
*/
Utils.FOCUSABLE_SELECTORS = Utils.FOCUSABLE_SELECTORS || 'a[href], area[href], summary, :is(select, textarea, button, input:not([type="hidden"])):not([disabled]), [role="button"], [tabindex="0"], [contenteditable]';
// ---------------------------------------------------------------------------
// RÉSOLUTION RESPONSIVE PAR LARGEUR DE CONTAINER
/**
* Résout une valeur responsive (items, peek, gap, buttons, counter, spotlight)
* selon la largeur courante du container.
*
* Accepte soit un scalaire (valeur fixe), soit un objet mobile-first dont les clés
* sont des seuils en px et la clé "default" sert de valeur de repli.
* Le seuil actif est le plus grand seuil inférieur ou égal à containerPx (px directs, sans conversion rem).
*
* @example
* resolveByWidth({ default: 1, "37.5": 2, "56.25": 3 }, 45, 16) // → 2
* resolveByWidth(2, 45) // → 2
*
* @param {number|boolean|Object} cfg - Valeur fixe ou objet responsive { default, "seuil": valeur, … }
* @param {number} containerPx - Largeur courante du container en px (offsetWidth)
*
* @returns {number|boolean}
*/
const resolveByWidth = (cfg, containerPx) => {
if (typeof cfg !== 'object' || cfg === null) return cfg;
if ('v' in cfg || 'h' in cfg) return cfg;
const active = Object.entries(cfg)
.filter(([key]) => key !== 'default' && containerPx >= Number(key))
.sort(([a], [b]) => Number(b) - Number(a))[0];
return active ? active[1] : (cfg.default ?? 1);
};
/**
* Résout la configuration grid active selon la largeur du container.
* Sans seuil atteint, retourne undefined (comportement carrousel scroll standard).
* Normalise les deux formats acceptés en sortie : { cols, rows, skip }, rows étant
* undefined si non précisé (pas de limite de lignes), skip valant false par défaut.
*
* @example
* resolveGrid({ "1000": 3, "1400": 4 }, 1200)
* // → { cols: 3, rows: undefined, skip: false }
* resolveGrid({ "1000": { cols: 3, rows: 2 }, "1400": { cols: 4 } }, 1200)
* // → { cols: 3, rows: 2, skip: undefined }
* resolveGrid({ "1000": 3 }, 500)
* // → undefined
*
* @param {Object|null} cfg - Objet { "seuil": colonnes|{cols,rows}, … }, sans "default"
* @param {number} containerPx - Largeur courante du container en px
*
* @returns {{ cols: number, rows?: number, skip?: boolean }|undefined}
*/
const resolveGrid = (cfg, containerPx) => {
if (!cfg || typeof cfg !== 'object') return undefined;
const active = Object.entries(cfg)
.filter(([key]) => containerPx >= Number(key))
.sort(([a], [b]) => Number(b) - Number(a))[0];
if (!active) return undefined;
const value = active[1];
return typeof value === 'object' && value !== null ? {cols: value.cols, rows: value.rows, skip: value.skip} : {cols: value, rows: undefined, skip: false};
};
// ---------------------------------------------------------------------------
// I18N
// ---------------------------------------------------------------------------
/**
* Chaînes de caractères par défaut (français).
* Pour localiser, définir window.i18n avec les mêmes clés avant le chargement du script.
* Les tokens %d (nombre) et %s (pluriel) sont remplacés dynamiquement par t().
* @type {Object<string, string>}
*/
const defaultTranslations = {
skipBefore: 'Passer la liste de %d élément%s', skipAfter: 'Revenir avant la liste de %d élément%s', labelLeft: 'Faire défiler la liste vers la gauche', labelLeftDisabled: 'Bouton inactif, la liste est au début', labelRight: 'Faire défiler la liste vers la droite', labelRightDisabled: 'Bouton inactif, la liste est à la fin', labelEscape: 'Passer %d élément%s avec touche d\'échappement. Faire défiler au clavier avec les touches flèches droite et gauche', indexSeparator: ' sur ', plural: 's', autoplayStop: 'Arrêter le défilement automatique', autoplayStart: 'Démarrer le défilement automatique',
};
/**
* Retourne la chaîne traduite pour une clé donnée, avec remplacement des tokens.
* Cherche d'abord dans window.i18n, puis dans defaultTranslations.
* %d → count, %s → suffixe pluriel si count > 1.
*
* @param {string} key - Clé de traduction
* @param {number} [count=1] - Valeur numérique pour %d et le pluriel
* @returns {string}
*/
const t = (key, count = 1) => {
const i18n = window.i18n || {};
const str = i18n[key] || defaultTranslations[key] || '';
return str
.replace('%d', `${count}`)
.replace('%s', count > 1 ? (i18n.plural || defaultTranslations.plural) : '');
};
// ---------------------------------------------------------------------------
// HELPERS
// ---------------------------------------------------------------------------
/**
* Indique si la classe `.no-motion` est présente sur <html>.
* Permet à l'intégrateur de forcer l'arrêt des animations sans media query
* (ex. bascule dynamique depuis un bouton d'accessibilité).
* Appelé à chaque vérification (pas mis en cache) pour réagir aux changements en live.
* @returns {boolean}
*/
const noMotion = () => document.documentElement.classList.contains('no-motion');
/**
* Vrai si l'utilisateur a activé prefers-reduced-motion OU si .no-motion est présent
* au moment du chargement du script. Mis en cache une fois car la media query ne change
* pas en cours de session (contrairement à .no-motion qui est géré par noMotion()).
* Quand true, l'autoplay et son bouton play/pause ne sont pas du tout créés.
* @type {boolean}
*/
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches || noMotion();
// ---------------------------------------------------------------------------
// GESTION GLOBALE DE L'AUTOPLAY (visibilitychange)
// ---------------------------------------------------------------------------
/**
* Registre de toutes les instances ayant un autoplay actif.
* Permet de les mettre toutes en pause quand l'onglet passe en arrière-plan
* (visibilitychange) et de les relancer au retour.
* @type {Array<{ start: Function, stop: Function }>}
*/
const AUTOPLAY_INSTANCES = [];
let visibilityListenerAdded = false;
// ---------------------------------------------------------------------------
// INIT PRINCIPALE
// ---------------------------------------------------------------------------
/**
* Point d'entrée public. Initialise tous les `.simca` trouvés sous `fromDomNode`.
* Appelé automatiquement sur document.body au chargement.
* À rappeler manuellement après une injection de contenu dynamique en passant
* le nœud parent du nouveau contenu pour éviter de réinitialiser les instances
* existantes (le guard `is-init` les protège de toute façon).
*
* @param {HTMLElement|string} [fromDomNode=document.body] - Nœud racine ou sélecteur CSS
*/
Utils.simCa = (fromDomNode = document.body) => {
const root = fromDomNode instanceof HTMLElement ? fromDomNode : document.querySelector(fromDomNode);
if (!root) return;
root.querySelectorAll('.simca').forEach(initCarousel);
if (!visibilityListenerAdded) {
document.addEventListener('visibilitychange', () => {
AUTOPLAY_INSTANCES.forEach(({start, stop}) => document.visibilityState === 'visible' ? start() : stop());
});
visibilityListenerAdded = true;
}
};
// ---------------------------------------------------------------------------
// INIT D'UN CARROUSEL
// ---------------------------------------------------------------------------
/**
* Initialise une instance de carrousel à partir d'un élément `.simca`.
* Idempotent : la classe `is-init` empêche toute double initialisation.
*
* Séquence d'initialisation :
* 1. Parse de `data-properties` et résolution des valeurs responsive initiales
* 2. Si mode grid actif : pose des classes/custom properties statiques, arrêt immédiat
* (pas de boutons, compteur, loop, autoplay, bullets, listeners). Seuls les liens
* d'évitement peuvent être posés, via l'option grid.skip
* 3. Sinon, comportement carrousel inchangé : construction du DOM annexe, double rAF
* final → buildClones + spotlight IO + état initial
*
* Options reconnues dans data-properties : gap, peek, items, loop, buttons, counter,
* autoplay, spotlight, shadow, bullets, grid. Voir le JSDoc du fichier pour le détail
* de chaque option.
*
* @param {HTMLElement} simca - L'élément racine du carrousel (.simca)
*/
const initCarousel = (simca) => {
if (simca.classList.contains('is-init')) return;
// Pose le flag de scope CSS sur <html>. Idempotent, donc sans coût pour les
// instances injectées en fetch (Utils.simCa(noeud) repasse ici à chaque nouvelle instance).
document.documentElement.classList.add('use-simca');
// Mesure de performance — passer viewMark à true pour activer
const viewMark = true;
const markId = simca.id || simca.className;
viewMark && performance.mark(`simca-init-start:${markId}`);
simca.classList.add('is-init');
// -----------------------------------------------------------------------
// PARSE CONFIG
// -----------------------------------------------------------------------
/** @type {{ gap?: number, peek?: *, items?: *, buttons?: *, loop?: boolean, counter?: *, autoplay?: *, spotlight?: *, shadow?: *, bullets?: *, grid?: Object }} */
let cfg = {};
try {
cfg = JSON.parse(simca.dataset.properties || '{}');
} catch (e) {
console.warn('simCa: data-properties JSON invalide', e);
}
const gapCfg = cfg.gap ?? 0;
const peekCfg = cfg.peek ?? 1;
const itemsCfg = cfg.items ?? {default: 1};
const btnCfgRaw = cfg.buttons ?? null;
const autoplayCfg = cfg.autoplay ?? null;
// counter et spotlight acceptent le même format responsive que items/peek
const counterCfg = cfg.counter ?? false;
const shadowCfg = cfg.shadow ?? false;
// -----------------------------------------------------------------------
// DOM DE BASE
// -----------------------------------------------------------------------
const content = simca.querySelector('.simca-list');
if (!content) {
console.warn('simCa: .simca-list manquant');
return;
}
const items = Array.from(content.querySelectorAll('.simca-list-item'));
if (!items.length) {
console.warn('simCa: .simca-list-item manquant');
return;
}
items.forEach((item, i) => item.setAttribute('data-index', i));
const itemCount = items.length;
// -----------------------------------------------------------------------
// RÉSOLUTION RESPONSIVE INITIALE
// -----------------------------------------------------------------------
// rootFontSize en scope partagé — recalculé au resize pour gérer le zoom texte
let rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const containerPx = simca.offsetWidth / (rootFontSize / 16);
// -----------------------------------------------------------------------
// LIENS D'ÉVITEMENT (fabrique partagée carrousel / grid)
// -----------------------------------------------------------------------
/**
* Injecte les liens d'évitement avant et après la liste. Ils se ciblent
* mutuellement via un id unique par instance.
* @param {number} count - Nombre d'items annoncé dans les libellés
* @returns {{skipBefore: HTMLElement, skipAfter: HTMLElement, linkAfter: HTMLAnchorElement}}
*/
const addSkipLinks = (count) => {
const id = Date.now().toString(36) + Math.random().toString(36).slice(2);
const build = (cls, own, target, key) => {
const span = Object.assign(document.createElement('span'), {className: cls});
const link = span.appendChild(Object.assign(document.createElement('a'), {
id: `${own}-${id}`, href: `#${target}-${id}`, textContent: t(key, count)
}));
return [span, link];
};
const [skipBefore] = build('skip-list-first', 'prev', 'next', 'skipBefore');
const [skipAfter, linkAfter] = build('skip-list-last', 'next', 'prev', 'skipAfter');
simca.insertBefore(skipBefore, content);
simca.appendChild(skipAfter);
return {skipBefore, skipAfter, linkAfter};
};
// -----------------------------------------------------------------------
// MODE GRID : affichage statique, court-circuite tout le reste de l'init
// -----------------------------------------------------------------------
// Résolu une seule fois ici, jamais rebranché au resize (comme hasLoop/isSpotlight).
// Pas de "default" dans gridCfg : resolveGrid retourne undefined sous le 1er seuil,
// gridConf reste falsy → comportement carrousel scroll standard plus bas.
const gridCfg = cfg.grid ?? null;
const gridConf = resolveGrid(gridCfg, containerPx);
if (gridConf) {
simca.classList.add('simca-grid');
simca.style.setProperty('--simca-grid-cols', `${gridConf.cols}`);
simca.style.setProperty('--simca-gap', `${resolveByWidth(gapCfg, containerPx)}rem`);
// rows optionnel : limite le nombre de lignes affichées. Les items au-delà
// sont retirés du DOM (remove(), pas juste masqués) ou masqués pour rester dans le flux SEO — un crawler ne voit
// de toute façon que la version mobile scroll (le SEO se fait en mobile-first).
if (gridConf.rows) {
const maxItems = gridConf.cols * gridConf.rows;
items.slice(maxItems).forEach(item => {
// Suppression
// item.remove()
// ou masquage
item.inert = true;
item.style.height = 0;
item.style.width = 0;
item.style.overflow = 'hidden';
item.style.position = 'absolute';
});
}
// skip: true force les liens d'évitement en grille. Le compteur tient compte
// des items retirés/masqués par rows.
gridConf.skip && addSkipLinks(gridConf.rows ? Math.min(itemCount, gridConf.cols * gridConf.rows) : itemCount);
return;
}
// -----------------------------------------------------------------------
// SUITE : comportement carrousel inchangé
// -----------------------------------------------------------------------
// Fix Safari auto scroll : Safari résout le scroll-snap au premier paint, avant que
// --simca-item-width soit injecté. On désactive le snap le temps de l'init pour éviter
// un repositionnement parasite au chargement.
content.style.scrollSnapType = 'none';
// hasLoop résolu à l'init uniquement — pas de rebranch au resize
const hasLoop = !!resolveByWidth(cfg.loop ?? false, containerPx);
hasLoop && simca.classList.add('simca-loop');
// isSpotlight : requiert loop:true. La classe .simca-spotlight est posée ici par le JS ;
// ne pas la poser manuellement en HTML — elle sert de source de vérité pour le CSS.
const isSpotlight = hasLoop && !!resolveByWidth(cfg.spotlight ?? false, containerPx) && !prefersReducedMotion;
isSpotlight && simca.classList.add('simca-spotlight');
let currentGap = resolveByWidth(gapCfg, containerPx);
let currentItems = resolveByWidth(itemsCfg, containerPx);
// Mode statique : tous les items tiennent dans la vue, rien à faire défiler (voir applyStatic)
let isStatic = itemCount <= currentItems;
let currentPeek = resolveByWidth(peekCfg, containerPx) * currentGap;
// Offset (px) : écart entre le bord gauche du carrousel et celui du contenu de la page, mesuré sur le
// premier ancêtre plus étroit (bordure et padding déduits). Sans "default" explicite, inactif sous le
// premier seuil (contrairement à resolveByWidth, dont le repli est 1). 0 en loop ou sans ancêtre.
const offsetCfg = cfg.offset ?? false;
const getOffset = (px) => {
if (hasLoop || !resolveByWidth(typeof offsetCfg === 'object' ? {default: false, ...offsetCfg} : offsetCfg, px)) return 0;
const {left, width} = simca.getBoundingClientRect();
for (let el = simca.parentElement; el; el = el.parentElement) {
const r = el.getBoundingClientRect();
if (r.width < width - 1) {
const cs = getComputedStyle(el);
return Math.max(0, r.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft) - left);
}
}
return 0;
};
let currentOffset = getOffset(containerPx);
let currentBtn = resolveByWidth(btnCfgRaw, containerPx);
let currentCounter = !!resolveByWidth(counterCfg, containerPx);
let currentShadow = !!resolveByWidth(shadowCfg, containerPx);
currentShadow && simca.classList.add('simca-shadow');
// -----------------------------------------------------------------------
// CUSTOM PROPERTIES CSS
// -----------------------------------------------------------------------
/**
* Calcule et injecte les custom properties CSS utilisées par la feuille de style
* pour dimensionner les items et les gaps. Valeurs exprimées en rem pour rester
* cohérentes avec le zoom texte.
* Appelé à l'init et à chaque resize.
*
* Custom properties injectées sur l'élément `.simca` :
* --simca-gap Espacement entre items en rem
* --simca-peek Débordement de l'item suivant en rem
* --simca-items Nombre d'items visibles (utilisé en CSS si besoin)
* --simca-item-width Largeur exacte d'un item en rem
*
* @param {number} itemsVisible - Nombre d'items visibles simultanément
* @param {number} peekRem - Valeur peek résolue (multiplicateur × gap en rem), remplacée par la marge de droite (offset) en mode statique
* @param {number} gapRem - Espacement entre items en rem
*/
const setCSSProps = (itemsVisible, peekRem, gapRem) => {
// Statique : plus de peek à signaler. Avec offset, il est remplacé par une marge de droite
// égale à celle de gauche (sans offset, ni en loop, currentOffset vaut 0 : pleine largeur).
if (isStatic) peekRem = currentOffset / rootFontSize;
const gapPx = gapRem * rootFontSize;
const peekPx = peekRem * rootFontSize;
const usableWidth = simca.offsetWidth - (hasLoop ? 2 * peekPx : peekPx + currentOffset) - (itemsVisible - 1) * gapPx;
const itemWidthRem = (usableWidth / itemsVisible) / rootFontSize;
simca.style.setProperty('--simca-gap', `${gapRem}rem`);
simca.style.setProperty('--simca-peek', `${peekRem}rem`);
simca.style.setProperty('--simca-items', `${itemsVisible}`);
simca.style.setProperty('--simca-offset', `${currentOffset}px`);
simca.style.setProperty('--simca-item-width', `${itemWidthRem}rem`);
};
setCSSProps(currentItems, currentPeek, currentGap);
// -----------------------------------------------------------------------
// A11Y : focusabilité de la liste
// -----------------------------------------------------------------------
// Si la liste ne contient aucun élément nativement focusable (items purement visuels
// sans lien ni bouton), on rend la liste elle-même focusable au clavier pour que
// les flèches puissent quand même fonctionner.
// Posé / retiré par applyStatic (inutile en mode statique : rien à parcourir).
const needsTabindex = content.querySelectorAll(Utils.FOCUSABLE_SELECTORS).length === 0;
// -----------------------------------------------------------------------
// LIENS D'ÉVITEMENT
// -----------------------------------------------------------------------
// Deux liens sr-only permettent de sauter la liste ou d'y revenir rapidement
// au clavier (voir addSkipLinks). La touche Escape depuis n'importe où dans le
// carrousel saute directement au lien de sortie (linkAfter), sans avoir à tabuler
// à travers tous les items.
const {skipBefore, skipAfter, linkAfter} = addSkipLinks(itemCount);
simca.addEventListener('keyup', ({key}) => key === 'Escape' && linkAfter.focus());
// -----------------------------------------------------------------------
// GÉOMÉTRIE
// -----------------------------------------------------------------------
/**
* Calcule les mesures géométriques courantes du carrousel depuis le DOM.
* Appelé à chaque interaction (scroll, clic, resize) plutôt que mis en cache,
* car les valeurs peuvent changer après un resize ou un recalcul de layout.
*
* - itemWidth : largeur réelle d'un item (getBoundingClientRect pour les sous-pixels)
* - itemStride : distance entre le bord gauche de deux items consécutifs
* (= itemWidth + gap). Calculé depuis les offsetLeft plutôt que
* depuis les custom properties pour rester fidèle au rendu réel.
* - contentWidth : largeur visible du scroll container (viewport du carrousel)
* - pageStep : distance de défilement d'une page. = contentWidth, ou avec offset currentItems
* items exactement (la marge étant permanente, la page doit s'arrêter sur elle)
* - totalBreadth : largeur totale des items réels (sans les clones)
* - maxScrollLeft: position de scroll max avant la zone des clones de fin
* (sans loop, inclut le padding-inline-end pour le peek)
*
* @returns {{ itemWidth: number, itemStride: number, contentWidth: number, totalBreadth: number, maxScrollLeft: number }}
*/
const getGeometry = () => {
const itemWidth = items[0].getBoundingClientRect().width || 0;
const itemStride = items.length > 1 ? items[1].offsetLeft - items[0].offsetLeft : itemWidth;
const contentWidth = content.offsetWidth;
return {
itemWidth, itemStride, contentWidth, pageStep: currentOffset ? currentItems * itemStride : contentWidth, totalBreadth: itemStride * itemCount, maxScrollLeft: hasLoop ? itemStride * itemCount - contentWidth : content.scrollWidth - content.clientWidth,
};
};
// -----------------------------------------------------------------------
// LOOP : clonage du buffer
// -----------------------------------------------------------------------
/**
* Position de scroll correspondant au début des items réels (après les clones de tête).
* Initialisé par buildClones(), utilisé par loopReposition() et updateCounter().
* @type {number}
*/
let loopOffset = 0;
let clonesBefore = [];
let clonesAfter = [];
/**
* Neutralise un clone pour l'accessibilité :
* - le masque aux lecteurs d'écran (aria-hidden)
* - rend tous ses éléments focusables inatteignables au clavier (tabindex="-1")
* - purge les attributs id et les références aria pour éviter les doublons W3C
* - supprime le lazy loading sur les images pour garantir leur chargement
*
* @param {HTMLElement} clone
*/
const neutralizeClone = (clone) => {
clone.setAttribute('aria-hidden', 'true');
clone.setAttribute('data-clone', '');
const ARIA_ID_REFS = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns'];
[clone, ...clone.querySelectorAll('[id], [aria-labelledby], [aria-describedby], [aria-controls], [aria-owns], label[for]')]
.forEach(el => {
el.removeAttribute('id');
ARIA_ID_REFS.forEach(attr => el.removeAttribute(attr));
el.removeAttribute('for');
});
// Supprime le lazy loading : les clones sont souvent hors viewport au moment
// du clonage et ne seraient pas chargés, ou leur src serait perdu.
// On force eager + on réaffecte src/srcset pour déclencher le fetch.
clone.querySelectorAll('img[loading="lazy"]').forEach(img => {
img.removeAttribute('loading');
if (img.dataset.src) img.src = img.dataset.src; // lazyload JS natif
if (img.dataset.srcset) img.srcset = img.dataset.srcset;
// suppression du loading sur les images d'origine pour éviter l'effet flash au replacement du clone
simca.querySelectorAll(`[data-index="${img.closest('li').dataset.index}"] img`).forEach(i => {
i.removeAttribute('loading')
});
});
clone.querySelectorAll(Utils.FOCUSABLE_SELECTORS).forEach(el => el.setAttribute('tabindex', '-1'));
};
/**
* Supprime tous les clones existants du DOM et vide les tableaux de référence.
* Appelé avant chaque rebuild (resize, recalcul de bufferCount).
*/
const removeClones = () => {
[...clonesBefore, ...clonesAfter].forEach(c => c.remove());
clonesBefore = [];
clonesAfter = [];
};
/**
* Construit le buffer de clones nécessaire à la boucle infinie.
*
* Principe : on clone juste assez d'items pour remplir un viewport de chaque côté
* de la liste réelle. Ainsi, quand l'utilisateur défile jusqu'au bord, il voit
* des clones des items de l'autre extrémité, et loopReposition() peut téléporter
* silencieusement le scroll sans que l'utilisateur ne le perçoive.
*
* Structure du DOM après buildClones() :
* [clones de queue (fin → début)] [items réels] [clones de tête (début → fin)]
*
* loopOffset est mis à jour pour pointer sur le premier item réel.
* Le scroll est repositionné sur loopOffset sans animation (scrollBehavior: auto).
*
* Doit être appelé après stabilisation du layout (dans le double rAF final)
* pour que getBoundingClientRect() retourne une itemWidth fiable.
*/
const buildClones = () => {
if (!hasLoop) return;
removeClones();
loopOffset = 0;
// Mode statique : rien à boucler, donc pas de clones
if (isStatic) return;
const {itemWidth, contentWidth} = getGeometry();
if (!itemWidth) return;
const bufferCount = Math.ceil(contentWidth / itemWidth);
const tailFrag = document.createDocumentFragment();
for (let i = itemCount - bufferCount; i < itemCount; i++) {
const clone = items[(i + itemCount) % itemCount].cloneNode(true);
neutralizeClone(clone);
tailFrag.appendChild(clone);
clonesBefore.push(clone);
}
content.insertBefore(tailFrag, items[0]);
const headFrag = document.createDocumentFragment();
for (let i = 0; i < bufferCount; i++) {
const clone = items[i % itemCount].cloneNode(true);
neutralizeClone(clone);
headFrag.appendChild(clone);
clonesAfter.push(clone);
}
content.appendChild(headFrag);
loopOffset = bufferCount * itemWidth;
content.style.scrollBehavior = 'auto';
content.scrollLeft = loopOffset;
content.style.scrollBehavior = '';
};
// -----------------------------------------------------------------------
// REPOSITIONNEMENT SILENCIEUX (loop)
// -----------------------------------------------------------------------
/**
* Téléporte le scroll dans la zone des items réels quand il sort des limites.
* Appelé après chaque événement de fin de scroll (scrollend ou setTimeout fallback).
*
* Mécanisme : si scrollLeft est dans la zone des clones de tête ou de queue,
* on désactive temporairement scroll-snap (sinon le snap re-snappe pendant
* le repositionnement), on décale le scroll de ±totalBreadth, puis on
* réactive le snap via deux rAF imbriqués (un seul rAF ne suffit pas :
* le navigateur n'a pas encore appliqué le nouveau scrollLeft).
*/
const loopReposition = () => {
if (!hasLoop || isStatic) return;
const {totalBreadth} = getGeometry();
const cur = content.scrollLeft;
if (cur < loopOffset) {
content.style.scrollSnapType = 'none';
content.style.scrollBehavior = 'auto';
content.scrollLeft = cur + totalBreadth;
content.style.scrollBehavior = '';
requestAnimationFrame(() => requestAnimationFrame(() => content.style.scrollSnapType = ''));
} else if (cur >= loopOffset + totalBreadth) {
content.style.scrollSnapType = 'none';
content.style.scrollBehavior = 'auto';
content.scrollLeft = cur - totalBreadth;
content.style.scrollBehavior = '';
requestAnimationFrame(() => requestAnimationFrame(() => content.style.scrollSnapType = ''));
}
};
// -----------------------------------------------------------------------
// AUTOPLAY
// -----------------------------------------------------------------------
/** @type {number|null} Handle du setInterval en cours, null si stoppé. */
let autoplayTimer = null;
/** Direction courante : 1 = droite, -1 = gauche. S'inverse en bounce (sans loop). */
let autoplayDirection = autoplayCfg?.direction ?? 1;
/** True si l'utilisateur a explicitement mis en pause via le bouton play/pause. */
let isAutoplayManuallyPaused = false;
/** True si l'élément est dans le viewport (géré par IntersectionObserver). */
let isVisible = false;
/** Bouton play/pause injecté si l'autoplay est actif, sinon null (masqué en mode statique). */
let toggleBtn = null;
/**
* Clé localStorage pour persister l'état pause manuel entre les sessions.
* Null si l'élément n'a pas d'id (pas de persistance dans ce cas).
* @type {string|null}
*/
const storageKey = simca.id?.trim() ? `simca-autoplay-paused-${simca.id}` : null;
if (storageKey) {
try {
isAutoplayManuallyPaused = localStorage.getItem(storageKey) === 'true';
} catch {
}
}
/**
* Arrête l'autoplay en cours. Nettoie le timer et remet autoplayTimer à null.
* Sûr à appeler même si l'autoplay n'est pas actif.
*/
const stopAutoplay = () => {
if (autoplayTimer !== null) {
clearInterval(autoplayTimer);
autoplayTimer = null;
}
};
/**
* Démarre l'autoplay si toutes les conditions sont réunies :
* - autoplayCfg est défini
* - pas de prefers-reduced-motion ni de classe .no-motion
* - pas en pause manuelle
* - pas en mode statique
* - pas déjà en cours (timer existant)
*
* En mode loop : scroll d'un viewport dans la direction courante + reposition silencieux.
* Sans loop : bounce — inverse la direction quand on atteint un bord (seuil 32px).
*/
const startAutoplay = () => {
if (!autoplayCfg || isStatic || noMotion() || prefersReducedMotion) return;
if (isAutoplayManuallyPaused || autoplayTimer !== null) return;
autoplayTimer = setInterval(() => {
const {contentWidth, pageStep, maxScrollLeft} = getGeometry();
if (hasLoop) {
content.scrollLeft += contentWidth * autoplayDirection;
loopReposition();
return;
}
const cur = content.scrollLeft;
if (autoplayDirection === 1) {
content.scrollLeft = cur >= maxScrollLeft - 32 ? (autoplayDirection = -1, cur - pageStep) : cur + pageStep;
} else {
content.scrollLeft = cur <= 32 ? (autoplayDirection = 1, cur + pageStep) : cur - pageStep;
}
}, autoplayCfg.delay ?? 5000);
};
/**
* Tente de démarrer l'autoplay en vérifiant les pré-conditions contextuelles :
* onglet visible, élément dans le viewport, et .no-motion absent.
* Appelé après les pauses temporaires (hover, focus, visibilitychange).
*/
const tryStartAutoplay = () => {
document.visibilityState === 'visible' && isVisible && !noMotion() && startAutoplay();
};
// -----------------------------------------------------------------------
// BOUTONS + COMPTEUR
// -----------------------------------------------------------------------
const BTN_V = ['simca-btn-v-top', 'simca-btn-v-bottom'];
const BTN_H = ['simca-btn-h-left', 'simca-btn-h-center', 'simca-btn-h-right', 'simca-btn-h-split'];
/**
* Applique les classes de positionnement des boutons sur l'élément racine.
* Retire les classes précédentes avant d'en poser de nouvelles (gestion responsive).
* @param {{ v?: string, h?: string }|null} btn
*/
const applyBtnClasses = (btn) => {
simca.classList.remove(...BTN_V, ...BTN_H);
btn?.v && simca.classList.add(`simca-btn-v-${btn.v}`);
btn?.h && simca.classList.add(`simca-btn-h-${btn.h}`);
};
applyBtnClasses(currentBtn);
const btnWrapper = document.createElement('div');
btnWrapper.className = 'simca-buttons-wrapper';
/**
* Crée un bouton prev/next avec son span sr-only pour le label accessible.
* @param {'left'|'right'} dir
* @returns {{ btn: HTMLButtonElement, span: HTMLSpanElement }}
*/
const makeBtn = (dir) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.classList.add('simca-button', `direction-${dir}`);
const span = document.createElement('span');
span.className = ATClass;
btn.appendChild(span);
return {btn, span};
};
const {btn: btnLeft, span: spanLeft} = makeBtn('left');
const {btn: btnRight, span: spanRight} = makeBtn('right');
// Compteur injecté dans le wrapper si buttons v-top/bottom (entre les boutons),
// sinon sr-only hors wrapper. La visibilité est pilotée dynamiquement via
// applyCounterVisibility() pour réagir aux changements responsive.
const counterDisplay = document.createElement('p');
counterDisplay.setAttribute('role', 'status');
counterDisplay.setAttribute('aria-live', 'polite');
counterDisplay.setAttribute('aria-atomic', 'true');
counterDisplay.className = 'simca-pagination-counter';
/**
* Synchronise la classe sr-only du compteur avec l'état courant de
* currentCounter et currentBtn. Appelé à l'init et à chaque resize.
*/
const applyCounterVisibility = () => counterDisplay.classList.toggle(ATClass, !currentCounter || !currentBtn);
applyCounterVisibility();
btnWrapper.append(btnLeft, counterDisplay, btnRight);
// Boutons insérés après le lien d'évitement d'entrée, avant la liste défilante
simca.insertBefore(btnWrapper, content);
// -----------------------------------------------------------------------
// BULLETS (indicateur de page, purement décoratif)
// -----------------------------------------------------------------------
const bulletsCfg = cfg.bullets ?? false;
let currentBullets = !!resolveByWidth(bulletsCfg, containerPx) && !isSpotlight;
let bullets = [];
const bulletsWrapper = document.createElement('div');
bulletsWrapper.className = 'simca-bullets';
bulletsWrapper.setAttribute('aria-hidden', 'true');
bulletsWrapper.style.pointerEvents = 'none';
bulletsCfg && simca.insertBefore(bulletsWrapper, skipAfter);
/**
* Reconstruit les puces si le nombre de pages a changé (resize).
* @param {number} totalPages
*/
const buildBullets = (totalPages) => {
bulletsWrapper.replaceChildren();
bullets = Array.from({length: totalPages}, () => {
const b = document.createElement('span');
bulletsWrapper.appendChild(b);
return b;
});
};
/**
* Synchronise la puce active avec la page courante. No-op si bullets désactivées.
* @param {number} page
* @param {number} totalPages
*/
const updateBullets = (page, totalPages) => {
if (!currentBullets) return;
if (bullets.length !== totalPages) buildBullets(totalPages);
bullets.forEach((b, i) => b.classList.toggle('simca-bullet-active', i === page - 1));
};
// -----------------------------------------------------------------------
// SCROLL
// -----------------------------------------------------------------------
let scrollPrev, scrollNext;
if (!isSpotlight) {
scrollPrev = () => {
content.scrollLeft -= getGeometry().pageStep;
};
scrollNext = () => {
content.scrollLeft += getGeometry().pageStep;
};
} else {
scrollPrev = () => {
content.scrollLeft -= getGeometry().itemStride;
};
scrollNext = () => {
content.scrollLeft += getGeometry().itemStride;
};
}
// -----------------------------------------------------------------------
// ÉTAT DES BOUTONS
// -----------------------------------------------------------------------
/**
* Met à jour l'état aria-disabled et le label des boutons prev/next.
* En mode loop, les boutons ne sont jamais désactivés.
* Le seuil de 32px absorbe les arrondis sub-pixel selon les navigateurs.
*/
const updateButtons = () => {
const cur = content.scrollLeft;
const atStart = cur <= 32 && !hasLoop;
const atEnd = !hasLoop && cur >= content.scrollWidth - content.clientWidth - 32;
btnLeft[atStart ? 'setAttribute' : 'removeAttribute']('aria-disabled', 'true');
spanLeft.textContent = atStart ? t('labelLeftDisabled') : t('labelLeft');
btnRight[atEnd ? 'setAttribute' : 'removeAttribute']('aria-disabled', 'true');
spanRight.textContent = atEnd ? t('labelRightDisabled') : t('labelRight');
};
// -----------------------------------------------------------------------
// COMPTEUR
// -----------------------------------------------------------------------
/**
* Met à jour le compteur de pagination "page X sur Y".
* Le numéro de page est calculé depuis la position de scroll relative
* (hors clones en mode loop) divisée par la largeur d'un item,
* puis ramenée à un index de page selon currentItems.
* Le cas "fin de liste" est traité séparément pour afficher "N sur N"
* sans risque d'arrondi défavorable.
*
* Si itemWidth vaut 0 (layout non stabilisé, ex. Safari au premier paint),
* reporte l'appel d'un rAF et réessaie.
*/
const updateCounter = () => {
const {contentWidth, totalBreadth, itemWidth} = getGeometry();
if (!itemWidth) {
requestAnimationFrame(updateCounter);
return;
}
const totalPages = Math.ceil(itemCount / currentItems);
const isAtEnd = hasLoop ? content.scrollLeft >= loopOffset + totalBreadth - contentWidth - 5 : content.scrollLeft + content.clientWidth >= content.scrollWidth - 5;
if (isAtEnd) {
counterDisplay.textContent = `${totalPages}${t('indexSeparator')}${totalPages}`;
updateBullets(totalPages, totalPages);
return;
}
const scrollRelative = hasLoop ? content.scrollLeft - loopOffset : content.scrollLeft;
const rawIndex = Math.round(scrollRelative / itemWidth);
const page = Math.min(Math.max(1, Math.floor(rawIndex / currentItems) + 1), totalPages);
counterDisplay.textContent = `${page}${t('indexSeparator')}${totalPages}`;
updateBullets(page, totalPages);
};
// -----------------------------------------------------------------------
// LISTENERS scroll
// -----------------------------------------------------------------------
// scrollend est préféré à scroll+setTimeout car il se déclenche exactement
// une fois à la fin du scroll (y compris inertiel), sans polling.
// Le fallback setTimeout(150) couvre les navigateurs sans scrollend (Safari < 17).
if ('onscrollend' in window) {
content.addEventListener('scrollend', () => {
hasLoop && loopReposition();
updateButtons();
updateCounter();
}, {passive: true});
} else {
let scrollTimer;
content.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
hasLoop && loopReposition();
updateButtons();
updateCounter();
}, 150);
}, {passive: true});
}
// -----------------------------------------------------------------------
// LISTENERS boutons + clavier
// -----------------------------------------------------------------------
btnLeft.addEventListener('click', () => {
stopAutoplay();
scrollPrev();
});
btnRight.addEventListener('click', () => {
stopAutoplay();
scrollNext();
});
// Les flèches clavier scrollent d'un viewport. L'autoplay est stoppé définitivement
// (pas de reprise automatique) pour ne pas gêner la navigation au clavier.
// aria-disabled est respecté : on ne scrolle pas si le bouton est désactivé (sans loop).
simca.addEventListener('keydown', ({key}) => {
if (key === 'ArrowLeft') {
stopAutoplay();
if (btnLeft.getAttribute('aria-disabled') === 'true' && !hasLoop) return;
scrollPrev();
} else if (key === 'ArrowRight') {
stopAutoplay();
if (btnRight.getAttribute('aria-disabled') === 'true' && !hasLoop) return;
scrollNext();
}
});
// -----------------------------------------------------------------------
// PAUSE / REPRISE AUTOPLAY (hover, focus, touch)
// -----------------------------------------------------------------------
// Pause temporaire (non persistée) sur interaction utilisateur.
// La reprise se fait à la fin de l'interaction, sauf si l'utilisateur
// a explicitement mis en pause via le bouton play/pause (isAutoplayManuallyPaused).
['focusin', 'touchstart'].forEach(evt => content.addEventListener(evt, stopAutoplay, {passive: true}));
simca.addEventListener('mouseenter', stopAutoplay, {passive: true});
content.addEventListener('focusout', () => !isAutoplayManuallyPaused && tryStartAutoplay(), {passive: true});
simca.addEventListener('mouseleave', () => !isAutoplayManuallyPaused && tryStartAutoplay(), {passive: true});
// -----------------------------------------------------------------------
// BOUTON PLAY / PAUSE AUTOPLAY
// -----------------------------------------------------------------------
if (autoplayCfg && !prefersReducedMotion) {
simca.classList.add('simca-autoplay');
toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = isAutoplayManuallyPaused ? 'simca-start' : 'simca-stop';
toggleBtn.setAttribute('aria-label', isAutoplayManuallyPaused ? t('autoplayStart') : t('autoplayStop'));
skipBefore.after(toggleBtn);
/**
* Synchronise la classe et le label aria du bouton play/pause avec l'état courant.
* @param {boolean} paused - True si l'autoplay est en pause manuelle
*/
const updateToggle = (paused) => {
toggleBtn.className = paused ? 'simca-start' : 'simca-stop';
toggleBtn.setAttribute('aria-label', paused ? t('autoplayStart') : t('autoplayStop'));
};
toggleBtn.addEventListener('click', () => {
isAutoplayManuallyPaused = !isAutoplayManuallyPaused;
if (storageKey) {
try {
isAutoplayManuallyPaused ? localStorage.setItem(storageKey, 'true') : localStorage.removeItem(storageKey);
} catch {
}
}
isAutoplayManuallyPaused ? stopAutoplay() : tryStartAutoplay();
updateToggle(isAutoplayManuallyPaused);
});
// Si .no-motion est ajouté dynamiquement après le chargement (ex. bascule
// par un bouton d'accessibilité), on arrête l'autoplay, on met à jour le bouton
// et on le supprime du DOM (l'utilisateur a signifié qu'il ne voulait pas d'animation).
window.addEventListener('network:motion-change', () => {
if (noMotion()) {
stopAutoplay();
updateToggle(true);
toggleBtn.remove();
storageKey && localStorage.removeItem(storageKey);
}
});
AUTOPLAY_INSTANCES.push({start: tryStartAutoplay, stop: stopAutoplay});
}
// -----------------------------------------------------------------------
// MODE STATIQUE
// -----------------------------------------------------------------------
/**
* Applique l'état statique : si tous les items tiennent dans la vue, la liste ne défile
* pas. Masque (hidden) boutons, compteur, bullets, liens d'évitement et bouton play/pause,
* stoppe l'autoplay, retire les dégradés, et retire l'aria-label de navigation au clavier ainsi que le
* tabindex posé sur la liste. Pose .simca-static sur la racine pour le CSS.
* Appelé à l'init et à chaque resize, après mise à jour de isStatic.
*/
const applyStatic = () => {
simca.classList.toggle('simca-static', isStatic);
// Dégradés latéraux inutiles (et masquants) sans défilement
simca.classList.toggle('simca-shadow', currentShadow && !isStatic);
[btnWrapper, bulletsWrapper, skipBefore, skipAfter, toggleBtn].forEach(el => el && (el.hidden = isStatic));
// Reprise de l'autoplay si on repasse en non statique (sans effet à l'init : isVisible est encore false)
isStatic ? stopAutoplay() : tryStartAutoplay();
isStatic ? content.removeAttribute('aria-label') : content.setAttribute('aria-label', t('labelEscape', itemCount));
needsTabindex && (isStatic ? content.removeAttribute('tabindex') : content.setAttribute('tabindex', '0'));
};
applyStatic();
// -----------------------------------------------------------------------
// INTERSECTION OBSERVER (éco-conception — autoplay)
// -----------------------------------------------------------------------
// L'autoplay ne tourne que si le carrousel est visible à au moins 10% dans le viewport.
// Évite de consommer des ressources pour des carrousels hors écran (bas de page, onglets cachés).
if ('IntersectionObserver' in window) {
new IntersectionObserver((entries) => {
isVisible = entries[0].isIntersecting;
isVisible ? tryStartAutoplay() : stopAutoplay();
}, {threshold: 0.1}).observe(simca);
}
// -----------------------------------------------------------------------
// RESIZE
// -----------------------------------------------------------------------
// Recalcule les custom properties et rebuild les clones au resize fenêtre.
// Débounce à 200ms pour éviter de recalculer à chaque pixel pendant le redimensionnement.
let resizeTimer;
let currentWidth = simca.offsetWidth;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const w = simca.offsetWidth;
if (w === currentWidth) return;
currentWidth = w;
const containerPx = w / (rootFontSize / 16);
currentGap = resolveByWidth(gapCfg, containerPx);
currentItems = resolveByWidth(itemsCfg, containerPx);
isStatic = itemCount <= currentItems;
currentPeek = resolveByWidth(peekCfg, containerPx) * currentGap;
currentOffset = getOffset(containerPx);
currentBtn = resolveByWidth(btnCfgRaw, containerPx);
currentCounter = !!resolveByWidth(counterCfg, containerPx);
currentShadow = !!resolveByWidth(shadowCfg, containerPx);
currentBullets = !!resolveByWidth(bulletsCfg, containerPx) && !isSpotlight;
if (!currentBullets) {
bulletsWrapper.replaceChildren();
bullets = [];
}
simca.classList.toggle('simca-shadow', currentShadow);
setCSSProps(currentItems, currentPeek, currentGap);
applyBtnClasses(currentBtn);
applyCounterVisibility();
applyStatic();
if (hasLoop) buildClones();
updateButtons();
updateCounter();
}, 200);
}, {passive: true});
// -----------------------------------------------------------------------
// INIT FINAL
// Double rAF : Safari a besoin de deux cycles pour prendre en compte le scrollLeft
// avant de recalculer les snap points. On profite de cette séquence pour :
// 1. Restaurer scrollSnapType (désactivé en début d'init)
// 2. buildClones (layout stabilisé, itemWidth fiable)
// 3. Brancher l'IntersectionObserver spotlight sur les items réels
// 4. Mettre à jour l'état initial (compteur, boutons, autoplay)
const thresholdRatio = 0.65;
requestAnimationFrame(() => requestAnimationFrame(() => {
content.style.scrollSnapType = '';
if (hasLoop) {
buildClones();
// Spotlight : IntersectionObserver par item pour poser/retirer .simca-is-active.
// Branché après buildClones() — les items réels doivent être dans leur
// position définitive. Les clones ([data-clone]) ne sont pas observés.
if (isSpotlight) {
const io = new IntersectionObserver(entries => {
entries.forEach(({target, intersectionRatio}) => {
const isIntersect = intersectionRatio >= thresholdRatio;
// On pose .simca-is-active sur n'importe quel élément (clone ou original) au centre pour éviter
// l'effet de zoom à retardement.
target.classList.toggle('simca-is-active', isIntersect);
if (isIntersect) {
// On éteint tout le monde d'un coup
content.querySelectorAll('.simca-is-active').forEach(el => el.classList.remove('simca-is-active'));
// On allume la "famille" de l'index concerné
content.querySelectorAll(`[data-index="${target.dataset.index}"]`).forEach(item => {
item.classList.add('simca-is-active');
});
}
});
}, {root: content, threshold: thresholdRatio});
// On observe TOUS les items présents dans le DOM à ce moment-là
content.querySelectorAll('.simca-list-item').forEach(item => io.observe(item));
}
}
updateCounter();
updateButtons();
tryStartAutoplay();
}));
// -----------------------------------------------------------------------
// API PUBLIQUE DE L'INSTANCE
/**
* Stoppe l'autoplay et libère les ressources de l'instance.
* À appeler avant de supprimer l'élément du DOM.
* @memberof simca
*/
simca._simcaDestroy = () => stopAutoplay();
if (viewMark) {
performance.mark(`simca-init-end:${markId}`);
const measure = performance.measure(`simca-init:${markId}`, `simca-init-start:${markId}`, `simca-init-end:${markId}`);
console.info(`[simCa] init ${markId} : ${Math.round(measure.duration)}ms`);
}
};
Utils.simCa();
})();
Télécharger kortic-simca-v2.js
Feuille de styles
Les CSS sont en Nesting natif. Vérifiez votre cible pour éventuellement utiliser un pré-processeur.
Taille brute : 19,43 kilo-octets (~4,61 kilo-octets gzip)
/* Assurer le poids de CSS */
:where(:root.use-simca) {
.simca {
--simca-btn-size: 2.25rem;
--simca-btn-offset: calc(var(--simca-btn-size) / 4);
&:not(.simca-spotlight),
&.simca-spotlight.btn-on-peek {
&.simca-btn-medium {
--simca-btn-size: 2.75rem;
--simca-btn-offset: calc(var(--simca-btn-size) / 5);
}
&.simca-btn-small {
--simca-btn-size: 1.5rem;
--simca-btn-offset: calc(var(--simca-btn-size) / 3);
}
&.simca-btn-large {
--simca-btn-size: 3.125rem;
--simca-btn-offset: calc(var(--simca-btn-size) / 6);
}
}
/* Valeurs injectées par simca.js */
--simca-gap: 0;
--simca-peek: 0;
--simca-items: 1;
--simca-offset: 0;
/* définition des couleurs */
--btn-border-color: currentColor;
--btn-border-stroke: 0;
--btn-mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="m2.28,16.69l12.84,13.03c.37.38.98.38,1.35,0l1.16-1.17c.37-.38.37-.99,0-1.37l-7.6-7.71c-.6-.61-.18-1.66.68-1.66h18.34c.53,0,.96-.43.96-.97v-1.66c0-.54-.43-.97-.96-.97H10.71c-.85,0-1.28-1.05-.68-1.66l7.6-7.71c.37-.38.37-.99,0-1.37l-1.16-1.17c-.37-.38-.98-.38-1.35,0L2.28,15.31c-.37.38-.37.99,0,1.37Z"/></svg>');
--skip-mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M11.55,14.3l3.38,3.09V7.58l2.4-.08v9.65l3.01-3.09,1.66,1.55-4.25,4.41c-.86.88-2.29.91-3.2.08h0l-4.53-4.14,1.53-1.67h0ZM10.77,22.19h10.47c.41,0,.76.32.76.72v.83c.03.4-.29.75-.7.77h-10.53c-.41,0-.76-.32-.76-.72v-.91c.02-.39.35-.7.76-.7h0Z"/></svg>');
/* classe simca-autoplay ajoutée par le JS si nécessaire */
&.simca-autoplay {
--btn-start: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M27.39,12.03L10.75,1.61c-2.79-1.74-6.25.46-6.25,3.97v20.84c0,3.51,3.47,5.71,6.25,3.97l16.64-10.41c2.81-1.76,2.81-6.19,0-7.95"/></svg>');
--btn-stop: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M9.33,1c-1.84,0-3.33,1.68-3.33,3.75v22.5c0,2.07,1.49,3.75,3.33,3.75s3.33-1.68,3.33-3.75V4.75c0-2.07-1.49-3.75-3.33-3.75M26,4.75v22.5c0,2.07-1.49,3.75-3.33,3.75s-3.33-1.68-3.33-3.75V4.75c0-2.07,1.49-3.75,3.33-3.75s3.33,1.68,3.33,3.75"/></svg>');
}
position: relative;
contain: layout;
overflow-x: clip;
z-index: 0;
max-width: 100%;
width: 100%;
min-width: 0;
--simca-bg-color: var(--canvas, Canvas);
--simca-fg-color: var(--canvasText, CanvasText);
/* -------------------------------------------------------------------------
BOUTONS DE NAVIGATION : toujours en absolu
Mode défaut : un bouton de chaque côté, centrés verticalement
------------------------------------------------------------------------- */
&:not(.simca-btn-v-top):not(.simca-btn-v-bottom) {
& .simca-button {
position: absolute;
top: 50%;
&.direction-left {
left: var(--simca-btn-offset);
transform: translateY(-50%);
}
&.direction-right {
right: var(--simca-btn-offset);
transform: translateY(-50%) scaleX(-1);
}
}
}
& .simca-buttons-wrapper {
z-index: 10;
}
/* Mode v-top / v-bottom : c'est le wrapper qui se positionne */
&.simca-btn-v-bottom,
&.simca-btn-v-top {
& .simca-buttons-wrapper {
position: absolute;
left: var(--btn-border-stroke);
right: var(--btn-border-stroke);
display: flex;
gap: 1.5rem;
justify-content: flex-end; /* h-right par défaut */
}
& .simca-button.direction-right {
transform: scaleX(-1);
}
&.simca-btn-h-left .simca-buttons-wrapper {
justify-content: flex-start;
}
&.simca-btn-h-center .simca-buttons-wrapper {
justify-content: center;
}
&.simca-btn-h-split .simca-buttons-wrapper {
justify-content: space-between;
}
}
&.simca-btn-v-bottom {
padding-bottom: calc(max(var(--simca-btn-size), 2.25rem) + 1.5rem);
& .simca-buttons-wrapper {
bottom: 0.25rem;
top: auto;
}
}
&.simca-btn-v-top {
padding-top: calc(max(var(--simca-btn-size), 2.25rem) + 1.5rem);
& .simca-buttons-wrapper {
top: 0.25rem;
bottom: auto;
}
}
/* Retournement du bouton droit en mode défaut (côtés opposés) */
&:not(.simca-btn-v-top):not(.simca-btn-v-bottom) .simca-buttons-wrapper button ~ button {
transform: scaleX(-1);
}
/* -------------------------------------------------------------------------
STYLES DES BOUTONS
------------------------------------------------------------------------- */
& button[class*="simca-"] {
color: var(--simca-fg-color);
border-radius: max(0.3125rem, calc(var(--simca-btn-size) / 5)) !important;
box-shadow: 0 0 0 var(--btn-border-stroke) var(--btn-border-color) inset;
:root:not(.contrast) & {
background-color: transparent;
}
:root.contrast & {
background-color: var(--simca-bg-color);
}
height: var(--simca-btn-size);
width: var(--simca-btn-size);
z-index: 5;
margin: 0 !important;
@media (min-width: 48rem) {
height: var(--simca-btn-size);
width: var(--simca-btn-size);
}
&:after {
background-color: currentColor;
content: "";
-webkit-mask-position: center center;
-webkit-mask-size: 50% 50%;
-webkit-mask-repeat: no-repeat;
mask-position: center center;
mask-size: 50% 50%;
mask-repeat: no-repeat;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&.simca-start:after {
-webkit-mask-image: var(--btn-start);
mask-image: var(--btn-start);
}
&.simca-stop:after {
-webkit-mask-image: var(--btn-stop);
mask-image: var(--btn-stop);
}
&.simca-button:after {
-webkit-mask-image: var(--btn-mask);
mask-image: var(--btn-mask);
}
}
& .simca-button[aria-disabled] {
opacity: .5;
&::after {
background-color: var(--canvasText, CanvasText);
}
}
&:not([class*="simca-btn-h-"]):not([class*="simca-btn-v-"]) {
& button[class*="simca-"] {
background-color: var(--simca-bg-color);
}
}
/* Bouton play/pause */
& .simca-start,
& .simca-stop {
position: absolute;
top: var(--simca-btn-offset);
left: var(--simca-btn-offset);
background-color: var(var(--simca-fg-color));
}
@media (prefers-reduced-motion: no-preference) {
& .simca-button {
transition: opacity .3s ease;
}
}
/* Survol et focus clavier */
@media (any-hover: hover) {
& .simca-button[aria-disabled] {
cursor: not-allowed !important;
}
& button[class*="simca-"]:not([aria-disabled]):hover {
--btn-border-color: var(--simca-bg-color);
background-color: var(--simca-fg-color);
color: var(--simca-bg-color);
}
:root:not([class*='simca-persistent-buttons']) & {
&.simca-hide-buttons .simca-button {
opacity: 0;
}
&.simca-hide-buttons:hover .simca-button:not([aria-disabled]),
&.simca-hide-buttons:focus-within .simca-button:not([aria-disabled]) {
opacity: 1;
}
&.simca-hide-buttons:focus-within .simca-button[aria-disabled] {
opacity: .3;
}
}
:root:not([class*='simca-persistent-buttons']) &.simca-hide-buttons:focus-within .simca-button[aria-disabled] {
opacity: .3;
}
:root.use-keyboard:not([class*='simca-persistent-buttons']) &.simca-hide-buttons:focus-within .simca-button[aria-disabled]:focus {
opacity: 1;
}
}
:root.use-keyboard {
& button[class*="simca-"]:not([aria-disabled]):focus {
--btn-border-color: var(--simca-bg-color);
color: var(--simca-bg-color);
}
}
/* Contraste */
:root.contrast & button[class*="simca-"]:not([aria-disabled]) {
--btn-border-color: currentColor;
--btn-border-stroke: 0.1875rem;
}
/* -------------------------------------------------------------------------
BULLETS DÉCORATIVES
------------------------------------------------------------------------- */
.simca-bullets {
position: absolute;
inset-block-end: 1rem;
.simca.simca-btn-v-bottom & {
inset-block-start: 0.3125rem;
inset-block-end: auto;
}
left: 50%;
transform: translateX(-50%);
display: inline-flex;
justify-content: center;
gap: 0.3125rem;
padding: 0.3125rem;
border-radius: 0.9375rem;
:root:not(.contrast) & {
background-color: rgba(0, 0, 0, 40%);
box-shadow: inset 0 0 0 0.0625rem rgba(255, 255, 255, 25%);
}
:root.contrast & {
background-color: rgba(0, 0, 0, 80%);
box-shadow: inset 0 0 0 0.0625rem rgba(255, 255, 255, 80%);
}
& > span {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: white;
opacity: .54;
@media (prefers-reduced-motion: no-preference) {
:root:not(.no-motion) & {
transition: opacity .3s ease-in-out, scale .3s ease-in-out;
}
}
&.simca-bullet-active {
opacity: 1;
scale: 1.5;
}
}
}
/* -------------------------------------------------------------------------
LIENS D'ÉVITEMENT
------------------------------------------------------------------------- */
& [class*="skip-list"] {
position: absolute;
left: -625rem;
display: inline;
&:focus-within {
display: inline-block;
left: 0.3125rem;
z-index: 11;
}
& a {
font-size: max(1rem, 1.125rem);
line-height: 1;
font-weight: 600;
padding: 0.5rem 0.625rem 0.5rem 2rem;
position: relative;
color: light-dark(white, black) !important;
outline-color: currentColor;
background-color: light-dark(black, white);
border-radius: 0.1875rem;
display: inline-block;
&:before {
width: 0;
text-indent: -1.875rem;
display: inline-flex;
overflow: hidden;
font-size: 0;
}
&:after {
-webkit-mask-size: 1.5rem 1.5rem;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center center;
-webkit-mask-image: var(--skip-mask);
mask-size: 1.5rem 1.5rem;
mask-repeat: no-repeat;
mask-position: center center;
mask-image: var(--skip-mask);
position: absolute;
width: 1.5rem;
top: 0;
left: 0.1875rem;
bottom: 0;
content: "";
background-color: currentColor;
}
&[id*="next"]:after {
transform: scaleY(-1);
}
}
/* Langue FR */
:root[lang="fr"] & {
& a:before {
content: "Lien d'évitement ";
}
}
}
& .skip-list-first:focus-within {
bottom: 100%;
transform: translateY(calc(100% + 0.3125rem));
}
& .skip-list-last:focus-within {
top: 100%;
transform: translateY(calc(-100% - 0.3125rem));
}
/* -------------------------------------------------------------------------
COMPTEUR
------------------------------------------------------------------------- */
& .simca-pagination-counter {
width: 5rem;
text-align: center;
color: var(--canvasText, CanvasText);
:root.contrast & {
box-shadow: 0 0 0 1px var(--canvasText, CanvasText);
}
border-radius: var(--small-radius, 0.3125rem);
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&.simca-btn-small .simca-pagination-counter {
font-size: smaller;
line-height: 1;
}
/* -------------------------------------------------------------------------
MODE STATIQUE : tout tient dans la vue, simca.js masque les contrôles avec [hidden]
------------------------------------------------------------------------- */
/* Sans ça, les display posés plus haut (wrapper v-top/v-bottom, bullets, liens d'évitement)
l'emportent sur [hidden] */
& [hidden] {
display: none !important;
}
/* Plus de wrapper de boutons : on libère la place qui lui était réservée */
&.simca-static {
&.simca-btn-v-top,
&.simca-btn-v-bottom {
padding-block: 0;
}
}
/* -------------------------------------------------------------------------
LISTE SCROLLABLE
------------------------------------------------------------------------- */
& > .simca-list {
-moz-backface-visibility: hidden;
backface-visibility: hidden;
transform: translateZ(0); /* force la couche composite */
contain: layout style; /* isole reflow/repaint du scroll */
-ms-overflow-style: none;
scrollbar-width: none;
z-index: 2;
overflow-x: auto;
overflow-y: hidden;
display: flex;
flex: none;
flex-flow: row;
max-width: 100%;
gap: var(--simca-gap);
&:not(.simca-snap-proximity) {
scroll-snap-type: x mandatory;
}
&::-webkit-scrollbar {
display: none;
}
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
:root.no-motion & {
scroll-behavior: auto;
}
}
@media (prefers-reduced-motion: reduce) {
scroll-behavior: auto;
}
}
:root:not(.contrast) & {
&.simca-shadow {
&::before,
&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: max(2.5rem, calc(var(--simca-peek) - var(--simca-gap)));
z-index: 1;
pointer-events: none;
}
&.simca-btn-v-top {
&::before,
&::after {
top: calc(max(var(--simca-btn-size), 2.25rem) + 1.5rem);
}
}
&.simca-btn-v-bottom {
&::before,
&::after {
bottom: calc(max(var(--simca-btn-size), 2.25rem) + 1.5rem);
}
}
&::before {
left: 0;
background: linear-gradient(to right,
var(--canvas, Canvas) 0%,
transparent 100%
);
}
&::after {
right: 0;
background: linear-gradient(to left,
var(--canvas, Canvas) 0%,
transparent 100%
);
}
/**
Si pas de boucle, masquage du ::before gradient
*/
&:not(.simca-loop)::before {
opacity: 0;
}
}
}
/* Loop : peek symétrique des deux côtés */
&.simca-loop > .simca-list {
padding-inline: var(--simca-peek);
scroll-padding-inline: var(--simca-peek);
}
/*
Sans loop : peek côté droit uniquement. Avec offset (--simca-offset mesuré par simca.js),
marge permanente des deux côtés : le snap s'arrête sur elle à chaque page et en fin de liste
*/
&:not(.simca-loop) > .simca-list {
padding-inline: var(--simca-offset);
scroll-padding-inline: var(--simca-offset);
& .simca-list-item:last-child {
scroll-snap-align: end;
}
}
/* -------------------------------------------------------------------------
ITEMS
------------------------------------------------------------------------- */
& .simca-list-item {
flex: 0 0 var(--simca-item-width);
max-width: var(--simca-item-width);
scroll-snap-align: start;
scroll-snap-stop: always;
}
& li.simca-list-item {
list-style: none !important;
}
/* -------------------------------------------------------------------------
MODE GRID : affichage statique, classe posée par simca.js (option `grid`
dans data-properties). Court-circuite flex/scroll-snap : pas de carrousel
au-dessus du seuil configuré — pas de boutons/compteur/loop/autoplay/
bullets, ces éléments ne sont pas générés par le JS dans ce mode.
------------------------------------------------------------------------- */
&.simca-grid {
& > .simca-list {
display: grid;
grid-template-columns: repeat(var(--simca-grid-cols), 1fr);
gap: var(--simca-gap);
overflow: visible;
scroll-snap-type: none;
}
& .simca-list-item {
flex: initial;
max-width: none;
scroll-snap-align: none;
}
}
&.simca-spotlight {
container-type: inline-size;
&:not(.btn-on-peek) {
--btn-size: max(calc(var(--simca-gap) - 0.25rem), 1.5rem);
}
&.btn-on-peek {
--btn-size: var(--simca-btn-size);
}
@container (min-width: 37.5rem) {
&:not(.simca-btn-v-top):not(.simca-btn-v-bottom) {
& .simca-button {
width: var(--btn-size);
height: var(--btn-size);
:not(.btn-on-peek) & {
:root:not(.contrast) & {
&:not(:hover) {
background-color: transparent;
}
}
&.direction-left {
left: calc(var(--simca-peek) - var(--btn-size) - 0.125rem);
}
&.direction-right {
right: calc(var(--simca-peek) - var(--btn-size) - 0.125rem);
}
}
.btn-on-peek & {
&.direction-left {
left: max(calc(var(--simca-peek) / 2 - var(--btn-size)), var(--btn-size));
}
&.direction-right {
right: max(calc(var(--simca-peek) / 2 - var(--btn-size)), var(--btn-size));
}
}
}
}
& .simca-list-item {
overflow: hidden;
}
/* tous les items en peek : scale centré + couche composite préparée */
& .simca-list-item > * {
scale: var(--simca-scale, 0.8);
translate: 0 0;
will-change: scale, translate;
}
& .simca-list-item.simca-is-active > * {
scale: 1;
will-change: auto;
}
& .simca-list-item:has(+ .simca-is-active) > * {
--tx: calc((1 - var(--simca-scale, 0.8)) * 50%);
translate: var(--tx) 0;
}
& .simca-list-item.simca-is-active + .simca-list-item > * {
--tx: calc((1 - var(--simca-scale, 0.8)) * -50%);
translate: var(--tx) 0;
}
@media (prefers-reduced-motion: no-preference) {
& .simca-list-item > * {
transition: scale 0.5s ease-in-out, translate 0.5s ease;
}
}
}
}
}
}
/* Surcharge utilitaire no-motion — hors nesting spotlight */
:root.no-motion .simca.simca-spotlight .simca-list-item > * {
transition: none;
}
Télécharger kortic-simca-v2.css
That's all, Folks !