From 359e8226962527d0bba9211fd2385f60fad45e08 Mon Sep 17 00:00:00 2001 From: shyamr Date: Mon, 13 Jul 2026 09:59:15 +0530 Subject: [PATCH 1/2] Initial commit --- libs/blocks/carousel/carousel.js | 84 ++++++++++---- test/blocks/carousel/carousel.test.js | 161 ++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 23 deletions(-) diff --git a/libs/blocks/carousel/carousel.js b/libs/blocks/carousel/carousel.js index e50c5639d9a..454a592a787 100644 --- a/libs/blocks/carousel/carousel.js +++ b/libs/blocks/carousel/carousel.js @@ -306,14 +306,63 @@ function handleLightboxButtons(lightboxBtns, el, slideWrapper) { }); } -function checkSlideForVideo(activeSlide) { - const video = activeSlide.querySelector('video'); - /* c8 ignore start */ - if (video?.played.length > 0 && !video?.paused) { - video.pause(); - syncPausePlayIcon(video); - } - /* c8 ignore end */ +// Hinting layouts intentionally show a peek of the neighboring slide, so a +// slide can still be clipping the wrapper's edge by a meaningful amount even +// once it's no longer the one you care about. Require majority visibility +// rather than any nonzero overlap before treating it as still "in view". +const VISIBILITY_THRESHOLD = 0.5; + +function getOverlapRatio(rect, wrapperRect) { + const overlapWidth = Math.max(0, Math.min(rect.right, wrapperRect.right) + - Math.max(rect.left, wrapperRect.left)); + const overlapHeight = Math.max(0, Math.min(rect.bottom, wrapperRect.bottom) + - Math.max(rect.top, wrapperRect.top)); + const rectArea = rect.width * rect.height; + if (!rectArea) return 0; + return (overlapWidth * overlapHeight) / rectArea; +} + +// Pauses a slide's video if it's actually playing and no longer +// sufficiently overlaps the carousel wrapper's visible (overflow: hidden) area. +function checkSlideForVideo(slide, wrapperRect) { + const video = slide.querySelector('video'); + if (!video || !video.played.length || video.paused) return; + if (getOverlapRatio(video.getBoundingClientRect(), wrapperRect) >= VISIBILITY_THRESHOLD) return; + video.pause(); + syncPausePlayIcon(video); +} + +function pauseVideosOutOfView(slides, wrapper) { + const wrapperRect = wrapper?.getBoundingClientRect(); + if (!wrapperRect) return; + slides.forEach((slide) => checkSlideForVideo(slide, wrapperRect)); +} + +// Waits for the slide track's own transform transition to finish (with a +// timed fallback, since edge cases may never fire a matching transitionend) +// before running the visibility check, since positions aren't final until +// the transition settles. Guarded to the track's own transform so a child +// slide's opacity transition (carousel.css .carousel-slide) can't resolve +// this early via event bubbling. +// Margin above the CSS transition (25ms reflow delay + .6s transform, +// see carousel.css .carousel-slides.is-ready) in case transitionend +// never fires for this move. +const TRANSITION_FALLBACK_MS = 700; + +function waitForSlideTransition(slideContainer, onDone) { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + // eslint-disable-next-line no-use-before-define + clearTimeout(fallbackTimer); + onDone(); + }; + const fallbackTimer = setTimeout(settle, TRANSITION_FALLBACK_MS); + slideContainer.addEventListener('transitionend', (e) => { + if (e.target !== slideContainer || e.propertyName !== 'transform') return; + settle(); + }, { once: true }); } // Sets a multiplier variable, used by CSS, to move the indicator dots. @@ -382,13 +431,6 @@ function setAriaHiddenAndTabIndex({ el: block, slides }, activeEl) { }); }); } -// hinting (tablet/mobile) -function isSlideVisible(currentIdx, targetIdx, n, isNext) { - if (isNext) return currentIdx === targetIdx || currentIdx === (targetIdx + 1) % n; - const prev = (targetIdx - 1 + n) % n; - const next = (targetIdx + 1) % n; - return currentIdx === prev || currentIdx === targetIdx || currentIdx === next; -} // Skip focus on touch events to prevent the browser from scrolling the page function focusNavButton(button, event) { @@ -416,6 +458,7 @@ function moveSlides(event, carouselElements) { const atBoundary = isNext ? atEnd : atStart; if (atBoundary) { checkCircularNav(carouselElements); + pauseVideosOutOfView(slides, slideContainer.closest('.carousel-wrapper')); return; } } @@ -425,17 +468,9 @@ function moveSlides(event, carouselElements) { let referenceSlide = slideContainer.querySelector('.reference-slide'); let activeSlide = slideContainer.querySelector('.active'); let activeSlideIndicator = controlsContainer.querySelector('.active'); - let skipPause = false; // hinting-tablet / hinting-mobile const isHintingMobile = (el.classList.contains('hinting-mobile') || el.classList.contains('hinting-center-mobile')) && isMobileVp.matches; - if (isHintingTablet(el) || isHintingMobile) { - const n = slides.length; - const currentIdx = carouselElements.currentActiveIndex; - const targetIdx = isNext ? (currentIdx + 1) % n : (currentIdx - 1 + n) % n; - skipPause = isSlideVisible(currentIdx, targetIdx, n, isNext); - } - if (!skipPause) checkSlideForVideo(activeSlide); // Track reference slide - last slide initially if (!referenceSlide) { @@ -527,6 +562,9 @@ function moveSlides(event, carouselElements) { const slideDelay = 25; slideContainer.classList.remove('is-ready'); setTimeout(() => slideContainer.classList.add('is-ready'), slideDelay); + + const wrapper = slideContainer.closest('.carousel-wrapper'); + waitForSlideTransition(slideContainer, () => pauseVideosOutOfView(slides, wrapper)); } export function getSwipeDistance(start, end) { diff --git a/test/blocks/carousel/carousel.test.js b/test/blocks/carousel/carousel.test.js index f37a0bcc6f8..b37d63c4f6b 100644 --- a/test/blocks/carousel/carousel.test.js +++ b/test/blocks/carousel/carousel.test.js @@ -626,3 +626,164 @@ describe('carousel disable-buttons deferred heights on desktop', () => { }); }); }); + +/** + * carousel.js doesn't load carousel.css, so slides have no real flex/order + * layout in this environment. getBoundingClientRect and video playback state + * are stubbed directly to exercise the visibility-ratio pause logic + * deterministically, the same way img dimensions are stubbed above. + */ +function stubPlayingVideo(video, { playedLength = 1, paused = false } = {}) { + let isPaused = paused; + Object.defineProperty(video, 'played', { configurable: true, get: () => ({ length: playedLength }) }); + Object.defineProperty(video, 'paused', { configurable: true, get: () => isPaused }); + video.pause = () => { isPaused = true; }; + return video; +} + +function stubRect(el, { left = 0, right = 0, top = 0, bottom = 0 }) { + el.getBoundingClientRect = () => ({ + left, right, top, bottom, width: right - left, height: bottom - top, + }); +} + +const WRAPPER_RECT = { left: 0, right: 100, top: 0, bottom: 100 }; + +// Waits past moveSlides' TRANSITION_FALLBACK_MS (700ms); no carousel.css means +// the slide track's transitionend never fires, so every move settles via the +// fallback timer rather than the real transition. +async function waitForMoveSettle() { + await new Promise((resolve) => { setTimeout(resolve, 800); }); +} + +describe('carousel video pause on navigation (visibility ratio)', () => { + let fixtureRoot; + + beforeEach(() => { + const html = multiSectionCarouselFixture({ + carouselClass: 'visibility-isolated', + blockTitle: 'Visibility isolated', + companions: [ + { h2: 'V1', body: '' }, + { h2: 'V2', body: '' }, + ], + }); + fixtureRoot = appendCarouselFixture(html); + init(fixtureRoot.querySelector('.carousel')); + }); + + afterEach(() => { + fixtureRoot?.remove(); + }); + + it('pauses a playing video once it drops below the visibility threshold after navigating away', async () => { + const el = fixtureRoot.querySelector('.visibility-isolated'); + const wrapper = el.querySelector('.carousel-wrapper'); + const slides = el.querySelectorAll('.carousel-slide'); + const outgoingVideo = stubPlayingVideo(slides[0].querySelector('video')); + + stubRect(wrapper, WRAPPER_RECT); + // Fully outside the wrapper's [0, 100] bounds: 0% overlap. + stubRect(outgoingVideo, { left: 200, right: 300, top: 0, bottom: 100 }); + + el.querySelector('.carousel-next').click(); + await waitForMoveSettle(); + + expect(outgoingVideo.paused).to.be.true; + }); + + it('keeps a playing video unpaused when it still meets the visibility threshold', async () => { + const el = fixtureRoot.querySelector('.visibility-isolated'); + const wrapper = el.querySelector('.carousel-wrapper'); + const slides = el.querySelectorAll('.carousel-slide'); + const outgoingVideo = stubPlayingVideo(slides[0].querySelector('video')); + + stubRect(wrapper, WRAPPER_RECT); + // 70% of the video's own 100x100 area overlaps the wrapper. + stubRect(outgoingVideo, { left: 30, right: 130, top: 0, bottom: 100 }); + + el.querySelector('.carousel-next').click(); + await waitForMoveSettle(); + + expect(outgoingVideo.paused).to.be.false; + }); + + it('pauses a video sitting just under the majority-visibility threshold', async () => { + const el = fixtureRoot.querySelector('.visibility-isolated'); + const wrapper = el.querySelector('.carousel-wrapper'); + const slides = el.querySelectorAll('.carousel-slide'); + const outgoingVideo = stubPlayingVideo(slides[0].querySelector('video')); + + stubRect(wrapper, WRAPPER_RECT); + // 40% overlap: below the 0.5 threshold, should pause. + stubRect(outgoingVideo, { left: 60, right: 160, top: 0, bottom: 100 }); + + el.querySelector('.carousel-next').click(); + await waitForMoveSettle(); + + expect(outgoingVideo.paused).to.be.true; + }); + + it('does not touch a video that was never played', async () => { + const el = fixtureRoot.querySelector('.visibility-isolated'); + const wrapper = el.querySelector('.carousel-wrapper'); + const slides = el.querySelectorAll('.carousel-slide'); + const outgoingVideo = stubPlayingVideo(slides[0].querySelector('video'), { playedLength: 0 }); + let pauseCalled = false; + outgoingVideo.pause = () => { pauseCalled = true; }; + + stubRect(wrapper, WRAPPER_RECT); + stubRect(outgoingVideo, { left: 200, right: 300, top: 0, bottom: 100 }); + + el.querySelector('.carousel-next').click(); + await waitForMoveSettle(); + + expect(pauseCalled).to.be.false; + }); +}); + +describe('carousel video pause at disable-circular-nav boundary', () => { + let fixtureRoot; + + beforeEach(() => { + const html = multiSectionCarouselFixture({ + carouselClass: 'disable-circular-nav boundary-video-isolated', + blockTitle: 'Boundary video isolated', + companions: [ + { h2: 'B1' }, + { h2: 'B2', body: '' }, + ], + }); + fixtureRoot = appendCarouselFixture(html); + init(fixtureRoot.querySelector('.carousel')); + }); + + afterEach(() => { + fixtureRoot?.remove(); + }); + + it('pauses an out-of-view playing video even when next is a blocked no-op at the boundary', () => { + const el = fixtureRoot.querySelector('.boundary-video-isolated'); + const wrapper = el.querySelector('.carousel-wrapper'); + const slides = el.querySelectorAll('.carousel-slide'); + const next = el.querySelector('.carousel-next'); + + // Move to the last slide first; this is a real move, not the blocked boundary case. + next.click(); + expect(slides[1].classList.contains('active')).to.be.true; + expect(next.disabled).to.be.true; + + const video = stubPlayingVideo(slides[1].querySelector('video')); + stubRect(wrapper, WRAPPER_RECT); + stubRect(video, { left: 200, right: 300, top: 0, bottom: 100 }); + + // next is disabled, so a real .click() is suppressed by the browser + // before it ever reaches the listener. dispatchEvent bypasses that, + // invoking moveSlides directly so it takes the early-return boundary + // path - this is what carousel.js's disable-circular-nav check calls + // pauseVideosOutOfView from, with no transition to wait for. + next.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(video.paused).to.be.true; + }); +}); From aaba79efe514093c29ab6191b0fe28225f90007f Mon Sep 17 00:00:00 2001 From: shyamr Date: Tue, 21 Jul 2026 10:29:33 +0530 Subject: [PATCH 2/2] Minor fix --- libs/blocks/carousel/carousel.js | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/libs/blocks/carousel/carousel.js b/libs/blocks/carousel/carousel.js index 454a592a787..a2c7d4d5549 100644 --- a/libs/blocks/carousel/carousel.js +++ b/libs/blocks/carousel/carousel.js @@ -306,10 +306,8 @@ function handleLightboxButtons(lightboxBtns, el, slideWrapper) { }); } -// Hinting layouts intentionally show a peek of the neighboring slide, so a -// slide can still be clipping the wrapper's edge by a meaningful amount even -// once it's no longer the one you care about. Require majority visibility -// rather than any nonzero overlap before treating it as still "in view". +// Hinting layouts intentionally peek the neighboring slide, so require +// majority overlap (not just any overlap) to count a slide as still "in view". const VISIBILITY_THRESHOLD = 0.5; function getOverlapRatio(rect, wrapperRect) { @@ -338,16 +336,12 @@ function pauseVideosOutOfView(slides, wrapper) { slides.forEach((slide) => checkSlideForVideo(slide, wrapperRect)); } -// Waits for the slide track's own transform transition to finish (with a -// timed fallback, since edge cases may never fire a matching transitionend) -// before running the visibility check, since positions aren't final until -// the transition settles. Guarded to the track's own transform so a child -// slide's opacity transition (carousel.css .carousel-slide) can't resolve -// this early via event bubbling. -// Margin above the CSS transition (25ms reflow delay + .6s transform, -// see carousel.css .carousel-slides.is-ready) in case transitionend -// never fires for this move. -const TRANSITION_FALLBACK_MS = 700; +// Waits for the slide track's transform transition to finish (falling back to +// a timer in case transitionend never fires) before checking visibility, +// since slide positions aren't final until the transition settles. Filtered +// to the track's own transform so a child slide's opacity transition +// (carousel.css .carousel-slide) can't resolve this early via bubbling. +const TRANSITION_FALLBACK_MS = 700; // .6s CSS transition + 25ms reflow delay, plus margin function waitForSlideTransition(slideContainer, onDone) { let settled = false;