diff --git a/blocks/gallery/gallery.css b/blocks/gallery/gallery.css new file mode 100644 index 0000000..5d67d19 --- /dev/null +++ b/blocks/gallery/gallery.css @@ -0,0 +1,257 @@ +.gallery { + display: flex; + flex-direction: column; + gap: var(--space-s); +} + +.gallery .slides-wrapper { + position: relative; + min-width: 0; +} + +.gallery .slides { + list-style: none; + display: flex; + overflow: scroll clip; + margin: 0; + padding: 0; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; +} + +.gallery .slides::-webkit-scrollbar { + display: none; +} + +.gallery .slide { + flex: 0 0 100%; + width: 100%; + scroll-snap-align: start; +} + +.gallery .slide figure { + margin: 0; +} + +@media (width >= 900px) { + .gallery { + flex-direction: row; + align-items: stretch; + } + + .gallery .slides-wrapper { + flex: 1; + } +} + +.gallery .media-wrapper { + position: relative; + overflow: hidden; + background-color: var(--color-bg-light); + aspect-ratio: 16 / 9; +} + +.gallery .media-wrapper picture { + position: absolute; + inset: 0; +} + +.gallery .media-wrapper img { + height: 100%; + width: 100%; + object-fit: contain; +} + +.gallery .media-wrapper .video-embed { + height: 100%; + width: 100%; +} + +.gallery .media-wrapper .video-embed iframe { + height: 100%; + width: 100%; + border: none; +} + +.gallery .media-wrapper .video-embed .placeholder { + position: absolute; + inset: 0; + margin: 0; + cursor: pointer; +} + +.gallery .media-wrapper .video-embed .placeholder img { + height: 100%; + width: 100%; + object-fit: cover; +} + +.gallery .media-wrapper .video-embed .placeholder .icon { + position: absolute; + top: 50%; + left: 50%; + height: 80px; + width: 80px; + color: var(--color-white); + filter: drop-shadow(0 1px 5px rgb(0 0 0 / 60%)); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.gallery .body-wrapper { + padding: var(--space-s) 0; +} + +.gallery .body-wrapper .count { + font-weight: bold; +} + +/* controls */ + +.gallery .controls { + display: flex; + align-items: center; + justify-content: space-between; + position: absolute; + inset-block-start: 0; + inset-inline: 0; + z-index: 3; + aspect-ratio: 16 / 9; + pointer-events: none; +} + +.gallery .controls .button { + display: flex; + align-items: center; + justify-content: center; + height: 44px; + width: 44px; + margin: var(--space-xs); + border: 1px solid var(--color-border); + border-radius: 50%; + padding: 0; + background-color: var(--color-bg); + color: var(--color-text); + opacity: 0.6; + font-size: var(--heading-m); + cursor: pointer; + pointer-events: auto; + transition: background-color 0.2s, color 0.2s, opacity 0.2s; +} + +.gallery .controls .button[hidden] { + visibility: hidden; +} + +.gallery .controls .button:hover, +.gallery .controls .button:focus-visible { + background-color: var(--color-brand); + color: var(--color-bg); + opacity: 1; +} + +/* thumbnails */ + +.gallery .thumbnails { + order: -1; +} + +.gallery .thumbnails ol { + list-style: none; + display: flex; + flex-direction: row; + gap: var(--space-s); + overflow-x: auto; + margin: 0; + padding: 0; + scrollbar-color: var(--color-border) var(--color-bg); +} + +.gallery .thumbnails ol::-webkit-scrollbar { + height: 5px; + width: 5px; +} + +.gallery .thumbnails ol::-webkit-scrollbar-track { + background-color: var(--color-bg); +} + +.gallery .thumbnails ol::-webkit-scrollbar-thumb { + background-color: var(--color-border); +} + +.gallery .thumbnail { + display: block; + margin: 0; + border: 0; + padding: 0; + background: none; + cursor: pointer; +} + +.gallery .thumbnail::after { + content: ''; + display: block; + height: 5px; + margin-top: var(--space-100); + background-color: transparent; + transition: background-color 0.2s; +} + +.gallery .thumbnail[aria-current="true"]::after { + background-color: var(--color-brand); +} + +.gallery .thumbnail picture, +/* stylelint-disable-next-line no-descending-specificity */ +.gallery .thumbnail img { + display: block; + height: auto; + width: 100px; + aspect-ratio: 16 / 9; + object-fit: cover; +} + +.gallery .thumbnail picture { + filter: brightness(0.6); + transition: filter 0.2s; +} + +.gallery .thumbnail:hover picture, +.gallery .thumbnail:focus-visible picture, +.gallery .thumbnail[aria-current="true"] picture { + filter: brightness(100%); +} + +@media (width >= 900px) { + .gallery .thumbnails { + flex-shrink: 0; + order: 1; + position: relative; + width: 170px; + } + + .gallery .thumbnails ol { + flex-direction: column; + position: absolute; + inset: 0; + overflow: visible auto; + padding-bottom: calc(5px + var(--space-100)); + } + + .gallery .thumbnail { + position: relative; + width: 100%; + } + + .gallery .thumbnail::after { + position: absolute; + top: 100%; + width: 100%; + } + + .gallery .thumbnail picture, + .gallery .thumbnail img { + width: 100%; + } +} diff --git a/blocks/gallery/gallery.js b/blocks/gallery/gallery.js new file mode 100644 index 0000000..5f2d3fb --- /dev/null +++ b/blocks/gallery/gallery.js @@ -0,0 +1,312 @@ +import { isYouTubeHref, loadCopy } from '../../scripts/scripts.js'; +import { createOptimizedPicture, decorateIcons } from '../../scripts/aem.js'; +import { createYouTubeEmbed, createPlaceholder } from '../video/video.js'; + +/** + * Returns `true` if a column child is a paragraph containing a single YouTube link. + * @param {HTMLElement} el - Column child element to test + * @returns {boolean} + */ +function isVideoLink(el) { + if (el.tagName !== 'P' || el.children.length !== 1) return false; + const a = el.firstElementChild; + return a.tagName === 'A' && isYouTubeHref(a.href); +} + +/** + * Replaces a media column's YouTube link with a lazy-loaded embed behind a poster placeholder. + * @param {HTMLElement} col - Media column containing the link + * @param {string} href - YouTube URL to embed + */ +function decorateVideo(col, href) { + const embed = createYouTubeEmbed(href); + if (!embed) return; + + const container = document.createElement('div'); + container.className = 'video-embed'; + + const picture = col.querySelector('picture'); + const placeholder = createPlaceholder(picture, () => { + const src = new URL(embed.src); + src.searchParams.set('autoplay', 1); + embed.src = src.href; + if (!embed.isConnected) container.append(embed); + }); + + if (placeholder) container.append(placeholder); + col.replaceChildren(container); + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + container.append(embed); + observer.disconnect(); + }); + }, { threshold: 1 }); + observer.observe(container); +} + +/** + * Finds a YouTube link in a media column and converts it to a video embed. + * @param {HTMLElement} col - Media column to inspect and potentially transform + */ +function transformVideoLinks(col) { + const videoLink = [...col.children].find(isVideoLink); + if (videoLink) decorateVideo(col, videoLink.firstElementChild.href); +} + +/** + * Builds a slide's media figure and caption from the row's media/caption columns. + * @param {HTMLElement} row - Authored row div with one or two column divs + * @param {HTMLElement} slide - Slide list item to populate + * @param {number} slideIndex - Zero-based index of this slide + * @param {number} total - Total number of slides in the gallery + * @param {Object} copy - Localized UI strings + */ +function layoutGallerySlide(row, slide, slideIndex, total, copy) { + const [mediaCol, captionCol] = row.querySelectorAll(':scope > div'); + if (!mediaCol) return; + + transformVideoLinks(mediaCol); + mediaCol.className = 'media-wrapper'; + + const figure = document.createElement('figure'); + figure.append(mediaCol); + slide.append(figure); + + const hasCaption = captionCol && captionCol.textContent.trim(); + if (total > 1 || hasCaption) { + const figcaption = document.createElement('figcaption'); + figcaption.className = 'body-wrapper'; + if (total > 1) { + const count = document.createElement('p'); + count.className = 'count'; + count.textContent = `${slideIndex + 1} ${copy.of || 'of'} ${total}`; + figcaption.append(count); + } + if (hasCaption) figcaption.append(...captionCol.children); + figure.append(figcaption); + } +} + +/** + * Returns the source image src/alt to build a slide's thumbnail from, or `null` if none. + * @param {HTMLElement} slide - Slide list item + * @returns {{src: string, alt: string}|null} + */ +function getSlideThumbnail(slide) { + const img = slide.querySelector('.media-wrapper img'); + if (!img || !img.src) return null; + return { src: img.src, alt: img.alt || '' }; +} + +/** + * Creates a low-res thumbnail button for a slide, skipping slides with no usable image. + * @param {HTMLElement} slide - Slide the thumbnail navigates to + * @param {number} i - Zero-based index of the slide + * @param {number} total - Total number of slides in the gallery + * @param {Object} copy - Localized UI strings + * @param {HTMLOListElement} thumbList - List to append the thumbnail item to + */ +function createThumbnail(slide, i, total, copy, thumbList) { + const thumb = getSlideThumbnail(slide); + if (!thumb) return; + + const item = document.createElement('li'); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'thumbnail'; + button.dataset.targetSlide = i; + button.setAttribute('aria-label', `${copy.showSlide || 'Show Slide'} ${i + 1} ${copy.of || 'of'} ${total}`); + button.append(createOptimizedPicture(thumb.src, thumb.alt, false, [{ width: '150' }])); + item.append(button); + thumbList.append(item); +} + +/** + * Creates a slide list item and lays out its content from the authored row. + * @param {HTMLElement} row - Authored row div for this slide + * @param {number} slideIndex - Zero-based index of this slide + * @param {number} total - Total number of slides in the gallery + * @param {Object} copy - Localized UI strings + * @param {number} id - Gallery instance id, used to build a unique slide id + * @returns {HTMLLIElement} + */ +function createSlide(row, slideIndex, total, copy, id) { + const slide = document.createElement('li'); + slide.dataset.slideIndex = slideIndex; + slide.setAttribute('id', `gallery-${id}-slide-${slideIndex}`); + slide.classList.add('slide'); + layoutGallerySlide(row, slide, slideIndex, total, copy); + return slide; +} + +/** + * Shows or hides the prev/next buttons based on how close the active slide is to either end. + * @param {HTMLElement} block - Gallery block element + * @param {number} slideIndex - Active slide index + */ +function updateNavigation(block, slideIndex) { + const slides = block.querySelectorAll('.slide'); + const prev = block.querySelector('.controls .prev'); + const next = block.querySelector('.controls .next'); + if (!prev || !next) return; + + prev.hidden = slideIndex <= 0; + next.hidden = slideIndex >= slides.length - 1; +} + +/** + * Syncs aria-hidden, tabindex, and the active thumbnail to the given slide. + * @param {HTMLElement} slide - Slide to mark as active + */ +function updateActiveSlide(slide) { + const block = slide.closest('.gallery'); + const slideIndex = parseInt(slide.dataset.slideIndex, 10); + block.dataset.activeSlide = slideIndex; + + block.querySelectorAll('.slide').forEach((aSlide, i) => { + const hidden = i !== slideIndex; + aSlide.setAttribute('aria-hidden', hidden); + aSlide.querySelectorAll('a[href], iframe').forEach((el) => { + if (hidden) el.setAttribute('tabindex', '-1'); + else el.removeAttribute('tabindex'); + }); + aSlide.querySelectorAll('[role="button"]').forEach((el) => { + el.setAttribute('tabindex', hidden ? '-1' : '0'); + }); + }); + + block.querySelectorAll('.thumbnail').forEach((button, i) => { + if (i === slideIndex) button.setAttribute('aria-current', 'true'); + else button.removeAttribute('aria-current'); + }); + + updateNavigation(block, slideIndex); +} + +/** + * Whether the user has requested reduced motion. + * @returns {boolean} + */ +function prefersReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** + * Scrolls to a target slide index and syncs all active state. + * @param {HTMLElement} block - Gallery block element + * @param {number} slideIndex - Target slide index (clamped to valid range) + */ +function showSlide(block, slideIndex = 0) { + const slides = block.querySelectorAll('.slide'); + const realSlideIndex = Math.max(0, Math.min(slideIndex, slides.length - 1)); + const activeSlide = slides[realSlideIndex]; + + updateActiveSlide(activeSlide); + block.querySelector('.slides').scrollTo({ + top: 0, + left: activeSlide.offsetLeft, + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + }); +} + +/** + * Attaches click handlers to thumbnails/arrows and an IntersectionObserver to keep + * the active slide in sync. + * @param {HTMLElement} block - Gallery block element + */ +function bindEvents(block) { + block.querySelectorAll('.thumbnail').forEach((button) => { + button.addEventListener('click', (e) => { + showSlide(block, parseInt(e.currentTarget.dataset.targetSlide, 10)); + }); + }); + + const prev = block.querySelector('.controls .prev'); + const next = block.querySelector('.controls .next'); + if (prev) { + prev.addEventListener('click', () => { + const i = parseInt(block.dataset.activeSlide, 10); + if (i > 0) showSlide(block, i - 1); + }); + } + if (next) { + next.addEventListener('click', () => { + const i = parseInt(block.dataset.activeSlide, 10); + const slides = block.querySelectorAll('.slide'); + if (i < slides.length - 1) showSlide(block, i + 1); + }); + } + + const slideObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) updateActiveSlide(entry.target); + }); + }, { threshold: 0.5 }); + block.querySelectorAll('.slide').forEach((slide) => slideObserver.observe(slide)); +} + +let galleryId = 0; + +/** + * Builds the gallery: media slides, thumbnail nav, arrow controls, and behavior. + * @param {HTMLElement} block - Gallery block element + */ +export default async function decorate(block) { + galleryId += 1; + block.setAttribute('id', `gallery-${galleryId}`); + const rows = block.querySelectorAll(':scope > div'); + const isSingleSlide = rows.length < 2; + + const copy = await loadCopy(import.meta.url); + block.setAttribute('role', 'region'); + block.setAttribute('aria-roledescription', copy.gallery || 'Gallery'); + + const slidesWrapper = document.createElement('div'); + slidesWrapper.className = 'slides-wrapper'; + + const slidesList = document.createElement('ul'); + slidesList.className = 'slides'; + + let thumbList; + rows.forEach((row, i) => { + const slide = createSlide(row, i, rows.length, copy, galleryId); + slidesList.append(slide); + if (!isSingleSlide) { + if (!thumbList) thumbList = document.createElement('ol'); + createThumbnail(slide, i, rows.length, copy, thumbList); + } + if (row.parentElement === block) row.remove(); + }); + + slidesWrapper.append(slidesList); + + if (!isSingleSlide) { + const controls = document.createElement('nav'); + controls.className = 'controls'; + controls.innerHTML = ` + + + `; + decorateIcons(controls); + slidesWrapper.append(controls); + } + + block.append(slidesWrapper); + + if (!isSingleSlide) { + const thumbNav = document.createElement('nav'); + thumbNav.className = 'thumbnails'; + thumbNav.setAttribute('aria-label', copy.gallerySlideControls || 'Gallery Slide Controls'); + thumbNav.append(thumbList); + block.append(thumbNav); + + bindEvents(block); + updateActiveSlide(slidesList.querySelector('.slide')); + } +} diff --git a/blocks/gallery/gallery.json b/blocks/gallery/gallery.json new file mode 100644 index 0000000..6c39d95 --- /dev/null +++ b/blocks/gallery/gallery.json @@ -0,0 +1,18 @@ +{ + "en": { + "gallery": "Gallery", + "gallerySlideControls": "Gallery Slide Controls", + "previousSlide": "Previous Slide", + "nextSlide": "Next Slide", + "showSlide": "Show Slide", + "of": "of" + }, + "fr": { + "gallery": "Galerie", + "gallerySlideControls": "Contrôles de la galerie", + "previousSlide": "Diapositive précédente", + "nextSlide": "Diapositive suivante", + "showSlide": "Afficher la diapositive", + "of": "sur" + } +}