feat: lightbox desktop pour images et galeries

- Lightbox custom avec Swiper (pas de nouvelle dépendance)
- Déclenché sur les figures éligibles uniquement (exclut figures dans
  <a> et dans les cards avec .link-block)
- Galeries : ouvre toutes les slides au bon index
- Fermeture : bouton ✕, clic overlay, Echap
- Captions : figcaption, p.caption (dans figure ou sibling)
- Cursor zoom-in desktop uniquement via figure[data-lightbox]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-02-27 15:57:35 +01:00
parent 9f2b85087d
commit 3d9a0421b3
4 changed files with 246 additions and 0 deletions

View file

@ -0,0 +1,81 @@
#lightbox {
position: fixed;
inset: 0;
z-index: calc(var(--z-header) + 10);
background-color: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.3s ease, visibility 0.3s ease;
body.lightbox-open & {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
#lightbox-close {
position: absolute;
top: var(--padding-body);
right: var(--padding-body);
background: none;
border: none;
color: var(--color-txt);
cursor: pointer;
padding: 8px;
z-index: 1;
line-height: 0;
svg {
width: 30px;
fill: var(--color-txt);
transition: fill 0.2s ease;
}
&:hover svg {
fill: var(--grey-400);
}
}
#lightbox-swiper {
width: 90vw;
.swiper-slide {
display: flex;
align-items: center;
justify-content: center;
figure {
display: flex;
flex-direction: column;
align-items: center;
cursor: default;
img {
max-width: 90vw;
max-height: calc(90dvh - 100px);
width: auto;
height: auto;
object-fit: contain;
}
figcaption {
margin-top: calc(var(--spacing) * 0.5);
color: var(--color-txt-light);
font-size: var(--fs-small);
text-align: center;
}
}
}
}
// Cursor zoom-in sur les figures éligibles desktop uniquement
@media (min-width: 1080px) {
figure[data-lightbox] {
cursor: zoom-in;
}
}

View file

@ -27,6 +27,7 @@
@import "components/card-block-small";
@import "components/card-open-graph";
@import "components/swiper";
@import "components/lightbox";
@import "components/slider-before-after";
@import "components/dl-table";
@import "components/bottom-bar";

162
assets/js/lightbox.js Normal file
View file

@ -0,0 +1,162 @@
import Swiper from 'https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.mjs';
const DESKTOP = window.matchMedia('(min-width: 1080px)');
// Inline du close.svg (assets/icons/close.svg)
const CLOSE_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect y="22.3249" width="31.1111" height="2.22222" transform="rotate(-45 0 22.3249)"/><rect x="1.80078" width="31.1111" height="2.22222" transform="rotate(45 1.80078 0)"/></svg>`;
let lightboxEl = null;
let lightboxSwiper = null;
function createLightboxDOM() {
const el = document.createElement('div');
el.id = 'lightbox';
el.setAttribute('aria-modal', 'true');
el.setAttribute('role', 'dialog');
el.setAttribute('aria-label', 'Image agrandie');
el.setAttribute('inert', '');
el.innerHTML = `
<button id="lightbox-close" aria-label="Fermer">${CLOSE_SVG}</button>
<div id="lightbox-swiper" class="swiper">
<div class="swiper-wrapper"></div>
<div class="swiper-button-prev"></div>
<div class="swiper-button-next"></div>
<div class="swiper-pagination"></div>
</div>
`;
document.body.appendChild(el);
el.querySelector('#lightbox-close').addEventListener('click', closeLightbox);
// Fermeture au clic sur le fond (pas sur le swiper)
el.addEventListener('click', (e) => {
if (e.target === el) closeLightbox();
});
return el;
}
function ensureLightboxDOM() {
if (!lightboxEl) {
lightboxEl = createLightboxDOM();
}
}
function getImageData(figure) {
const img = figure.querySelector('img');
// 1. figcaption dans la figure
// 2. p.caption dans la figure (horizontal-gallery)
// 3. p.caption sibling de la figure (covers : resource, news-item, impact…)
const captionEl =
figure.querySelector('figcaption') ||
figure.querySelector('p.caption') ||
(figure.nextElementSibling?.matches('p.caption') ? figure.nextElementSibling : null);
return {
src: img?.src || '',
alt: img?.alt || '',
caption: captionEl?.innerHTML || '',
};
}
function buildSlides(images) {
const wrapper = lightboxEl.querySelector('.swiper-wrapper');
wrapper.innerHTML = '';
images.forEach(({ src, alt, caption }) => {
const slide = document.createElement('div');
slide.className = 'swiper-slide';
slide.innerHTML = `
<figure>
<img src="${src}" alt="${alt}">
${caption ? `<figcaption>${caption}</figcaption>` : ''}
</figure>
`;
wrapper.appendChild(slide);
});
}
function openLightbox(images, startIndex = 0) {
ensureLightboxDOM();
buildSlides(images);
const swiperEl = lightboxEl.querySelector('#lightbox-swiper');
// Détruire l'instance précédente si elle existe
if (swiperEl.swiper) {
swiperEl.swiper.destroy(true, true);
}
lightboxSwiper = new Swiper(swiperEl, {
slidesPerView: 1,
initialSlide: startIndex,
speed: 400,
watchOverflow: true,
keyboard: { enabled: true },
navigation: {
nextEl: swiperEl.querySelector('.swiper-button-next'),
prevEl: swiperEl.querySelector('.swiper-button-prev'),
},
pagination: {
el: swiperEl.querySelector('.swiper-pagination'),
clickable: true,
},
a11y: {
prevSlideMessage: 'Image précédente',
nextSlideMessage: 'Image suivante',
},
});
lightboxEl.removeAttribute('inert');
document.body.classList.add('lightbox-open');
lightboxEl.querySelector('#lightbox-close').focus();
}
function closeLightbox() {
document.body.classList.remove('lightbox-open');
lightboxEl.setAttribute('inert', '');
}
function isEligible(figure) {
// Exclure les figures dans un <a>
if (figure.closest('a')) return false;
// Exclure si le parent direct contient un .link-block (card navigable)
if (figure.parentElement.querySelector(':scope > .link-block')) return false;
return true;
}
export function initLightbox() {
// Fermeture à la touche Echap
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('lightbox-open')) {
closeLightbox();
}
});
document.querySelectorAll('figure').forEach((figure) => {
if (!isEligible(figure)) return;
// Marquer les figures éligibles (pour le cursor CSS)
figure.setAttribute('data-lightbox', '');
figure.addEventListener('click', () => {
if (!DESKTOP.matches) return;
const slide = figure.closest('.swiper-slide');
if (slide) {
// Galerie : ouvrir toutes les images du swiper parent
const swiperEl = slide.closest('.swiper');
const allFigures = [...swiperEl.querySelectorAll('.swiper-slide > figure')];
const startIndex = allFigures.indexOf(figure);
openLightbox(allFigures.map(getImageData), startIndex);
} else {
// Image standalone
openLightbox([getImageData(figure)], 0);
}
});
});
}

View file

@ -9,6 +9,7 @@ import { initSliderBeforeAfter} from './sliderBeforeAfter.js';
import { navInvestigation } from './investigation.js';
import { progressBar, scrollBack} from './bottom-bar.js';
import { initSort } from './sort.js';
import { initLightbox } from './lightbox.js';
const responsiveMedium = 1080;
const responsiveSmall = 768;
@ -27,6 +28,7 @@ window.onload = async function () {
playVideo();
initDropdowns(responsiveSmall, responsiveSmallX);
initSwipers();
initLightbox();
progressBar();