Commit 5017f7fcc47 for woocommerce
commit 5017f7fcc47ee62a355b75f31d450d892d541fca
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date: Thu Oct 1 08:29:15 2026 +0200
Improve product gallery scrolling with native scroll snap (#69066)
* Improve product gallery scrolling with native scroll snap
* Add changelog entry for native product gallery scrolling
* Refactor product gallery observer setup
* Separate product gallery navigation test scenarios
* Simplify product gallery alignment resets
* Hide thumbnail focus outlines for pointer input
* Remove product gallery navigation interruption tests
* Restore one-second gallery swipe assertion timeout
* Update native product gallery swiping changelog
diff --git a/plugins/woocommerce/changelog/fix-native-product-gallery-scrolling b/plugins/woocommerce/changelog/fix-native-product-gallery-scrolling
new file mode 100644
index 00000000000..2d7f36388f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-native-product-gallery-scrolling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Introduce native swiping for the Product Gallery block.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
index aa04ab4af29..778e4cf9d27 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
@@ -37,6 +37,7 @@ const getArrowsState = ( imageIndex: number, totalImages: number ) => ( {
} );
const DIALOG_VIDEO_INTERSECTION_HEIGHT_RATIO = 0.25;
+const pendingViewerImages = new WeakMap< HTMLElement, number >();
const getVerticalIntersectionRatio = ( rect: DOMRect, rootRect: DOMRect ) => {
const height =
@@ -163,20 +164,74 @@ const computeArrowsState = ( imageData: number[], selectedImageId: number ) => {
return getArrowsState( index, imageData.length );
};
-/** Scroll both the large viewer and the thumbnail strip to the given image. */
-const scrollImageEverywhereIntoView = (
- imageId: number,
- behavior: ScrollBehavior = 'smooth'
+/** Update the selected image and arrow states without scrolling. */
+const updateSelectedImage = ( imageId: number ) => {
+ const context = getContext();
+ Object.assign( context, {
+ selectedImageId: imageId,
+ ...computeArrowsState( context.imageData, imageId ),
+ } );
+};
+
+/** Observe every slide and return a function to refresh their visibility. */
+const initSlidesObservers = (
+ container: HTMLElement,
+ observer: IntersectionObserver
+) => {
+ const slides = container.querySelectorAll( SELECTORS.largeImageWrapper );
+ const resetSlidesObservers = () => {
+ observer.disconnect();
+ slides.forEach( ( slide ) => observer.observe( slide ) );
+ };
+ resetSlidesObservers();
+ return resetSlidesObservers;
+};
+
+/** Resume visible-slide selection when user input interrupts navigation. */
+const initInterruptionEvents = (
+ container: HTMLElement,
+ resetSlidesObservers: () => void
+) => {
+ const onUserInput = () => {
+ if ( pendingViewerImages.delete( container ) ) {
+ // The visible slide may have crossed the threshold before the interruption.
+ resetSlidesObservers();
+ }
+ };
+ const events = [ 'pointerdown', 'wheel', 'keydown' ];
+ for ( const event of events ) {
+ container.addEventListener( event, onUserInput, {
+ capture: true,
+ passive: true,
+ } );
+ }
+ return () => {
+ for ( const event of events ) {
+ container.removeEventListener( event, onUserInput, true );
+ }
+ };
+};
+
+/** Reset viewer alignment when its width changes. */
+const initViewerResizeObserver = (
+ container: HTMLElement,
+ resetAlignment: () => void
) => {
- scrollImageIntoView( imageId, behavior );
- scrollThumbnailIntoView( imageId );
+ let width = container.clientWidth;
+ const observer = new ResizeObserver( () => {
+ if ( width !== container.clientWidth ) {
+ width = container.clientWidth;
+ resetAlignment();
+ }
+ } );
+ observer.observe( container );
+ return () => observer.disconnect();
};
/**
- * Mutate the gallery's reactive context to reflect a new visible image
- * set. Empty input restores the parent product's gallery from the iAPI
- * config. Also recomputes arrow states and scrolls the active slot into
- * view.
+ * Update the image set, selection, arrows and thumbnail position.
+ * Empty input restores the parent gallery from the iAPI config.
+ * The viewer watcher resets alignment after the slide layout updates.
*/
const updateVisibleImageSet = (
imageIds: number[],
@@ -190,22 +245,16 @@ const updateVisibleImageSet = (
nextImageData,
selectedImageId
);
- const arrowsState = computeArrowsState(
- nextImageData,
- nextSelectedImageId
- );
-
- context.imageData = nextImageData;
- context.selectedImageId = nextSelectedImageId;
+ // A reset must rerun the viewer watcher even when the image list is unchanged.
+ context.imageData = [ ...nextImageData ];
context.hideNextPreviousButtons = nextImageData.length <= 1;
- context.isDisabledPrevious = arrowsState.isDisabledPrevious;
- context.isDisabledNext = arrowsState.isDisabledNext;
+ updateSelectedImage( nextSelectedImageId );
if ( nextSelectedImageId === -1 ) {
return;
}
- scrollImageEverywhereIntoView( nextSelectedImageId, 'instant' );
+ scrollThumbnailIntoView( nextSelectedImageId );
};
/**
@@ -289,9 +338,21 @@ const scrollImageIntoView = (
return;
}
+ const direction =
+ getComputedStyle( scrollableContainer ).direction === 'rtl' ? -1 : 1;
+ const left = imageIndex * scrollableContainer.clientWidth * direction;
+ if ( Math.abs( scrollableContainer.scrollLeft - left ) > 1 ) {
+ pendingViewerImages.set( scrollableContainer, imageId );
+ } else {
+ pendingViewerImages.delete( scrollableContainer );
+ }
+
scrollableContainer.scrollTo( {
- left: imageIndex * scrollableContainer.clientWidth,
- behavior,
+ left,
+ behavior: window.matchMedia( '(prefers-reduced-motion: reduce)' )
+ .matches
+ ? 'instant'
+ : behavior,
} );
};
@@ -393,14 +454,7 @@ const productGallery = {
}
const imageId = imageData[ newImageIndex ];
- const { isDisabledPrevious, isDisabledNext } = getArrowsState(
- newImageIndex,
- imageData.length
- );
-
- context.isDisabledPrevious = isDisabledPrevious;
- context.isDisabledNext = isDisabledNext;
- context.selectedImageId = imageId;
+ updateSelectedImage( imageId );
if ( imageId !== -1 ) {
scrollImageIntoView( imageId );
@@ -430,19 +484,7 @@ const productGallery = {
if ( Number.isNaN( imageId ) ) {
return;
}
- const context = getContext();
- const newImageIndex = context.imageData.indexOf( imageId );
-
- context.selectedImageId = imageId;
-
- if ( newImageIndex >= 0 ) {
- const arrowsState = getArrowsState(
- newImageIndex,
- context.imageData.length
- );
- context.isDisabledPrevious = arrowsState.isDisabledPrevious;
- context.isDisabledNext = arrowsState.isDisabledNext;
- }
+ updateSelectedImage( imageId );
scrollImageIntoView( imageId );
scrollThumbnailIntoView( imageId );
@@ -472,7 +514,7 @@ const productGallery = {
actions.selectImage( newImageIndex );
},
- onViewerImageKeyDown: ( event: KeyboardEvent ) => {
+ onViewerImageKeyDown: withSyncEvent( ( event: KeyboardEvent ) => {
if ( event.key === 'Enter' || event.key === ' ' ) {
if ( event.key === ' ' ) {
event.preventDefault();
@@ -481,13 +523,15 @@ const productGallery = {
}
if ( event.key === 'ArrowRight' ) {
+ event.preventDefault();
actions.selectNextImage();
}
if ( event.key === 'ArrowLeft' ) {
+ event.preventDefault();
actions.selectPreviousImage();
}
- },
+ } ),
onDialogKeyDown: ( event: KeyboardEvent ) => {
if ( event.key === 'Escape' ) {
actions.closeDialog();
@@ -547,52 +591,6 @@ const productGallery = {
context.isDialogOpen = false;
document.body.classList.remove( CLASSES.dialogOpenBody );
},
- onTouchStart: ( event: TouchEvent ) => {
- const context = getContext();
- const { clientX } = event.touches[ 0 ];
- context.touchStartX = clientX;
- context.touchCurrentX = clientX;
- context.isDragging = true;
- },
- onTouchMove: ( event: TouchEvent ) => {
- const context = getContext();
- if ( ! context.isDragging ) {
- return;
- }
- const { clientX } = event.touches[ 0 ];
- context.touchCurrentX = clientX;
-
- // Only prevent default if there's significant horizontal movement
- const delta = clientX - context.touchStartX;
- if ( Math.abs( delta ) > 10 ) {
- event.preventDefault();
- }
- },
- onTouchEnd: () => {
- const context = getContext();
- if ( ! context.isDragging ) {
- return;
- }
-
- const SNAP_THRESHOLD = 0.2;
- const delta = context.touchCurrentX - context.touchStartX;
- const element = getElement()?.ref as HTMLElement;
- const imageWidth = element?.offsetWidth || 0;
-
- // Only trigger swipe actions if there was significant movement
- if ( Math.abs( delta ) > imageWidth * SNAP_THRESHOLD ) {
- if ( delta > 0 && ! context.isDisabledPrevious ) {
- actions.selectPreviousImage();
- } else if ( delta < 0 && ! context.isDisabledNext ) {
- actions.selectNextImage();
- }
- }
-
- // Reset touch state
- context.isDragging = false;
- context.touchStartX = 0;
- context.touchCurrentX = 0;
- },
onScroll: () => {
const scrollableElement = getElement()?.ref;
if ( ! scrollableElement ) {
@@ -650,6 +648,92 @@ const productGallery = {
},
},
callbacks: {
+ /**
+ * Keep thumbnails and arrows in sync with the visible slide:
+ * - Update thumbnail selection during native swipes and trackpad scrolling.
+ * - Keep the thumbnail clicked by the user selected unless interrupted by another user input.
+ * - Reset the selected image's alignment after variation or gallery width changes.
+ */
+ watchViewerScroll: () => {
+ const container = getElement()?.ref;
+ if ( ! ( container instanceof HTMLElement ) ) {
+ return;
+ }
+
+ // Reset alignment after variation changes, once slide visibility and order have updated.
+ const { imageData } = getContext();
+ let frame = 0;
+ const alignSelectedImage = withScope( () => {
+ scrollImageIntoView( getContext().selectedImageId, 'instant' );
+ frame = 0;
+ } );
+ const resetAlignment = () => {
+ cancelAnimationFrame( frame );
+ frame = requestAnimationFrame( alignSelectedImage );
+ };
+ // Avoid two slides qualifying when both are exactly half visible.
+ const visibilityThreshold = 0.51;
+ const observer = new IntersectionObserver(
+ withScope( ( entries: IntersectionObserverEntry[] ) => {
+ const context = getContext();
+ if ( frame || context.imageData !== imageData ) {
+ return;
+ }
+
+ const visibleSlide = entries.find(
+ ( entry ) =>
+ entry.intersectionRatio >= visibilityThreshold
+ );
+ const image =
+ visibleSlide?.target.querySelector< HTMLElement >(
+ '[data-image-id]'
+ );
+ const imageId = Number( image?.dataset.imageId );
+ const index = imageData.indexOf( imageId );
+ if ( index < 0 ) {
+ return;
+ }
+
+ const pendingImage = pendingViewerImages.get( container );
+ // Keep the clicked thumbnail selected while scrolling past intermediate images.
+ if (
+ pendingImage !== undefined &&
+ imageId !== pendingImage
+ ) {
+ return;
+ }
+ pendingViewerImages.delete( container );
+ if ( imageId === context.selectedImageId ) {
+ return;
+ }
+
+ updateSelectedImage( imageId );
+ scrollThumbnailIntoView( imageId );
+ } ),
+ { root: container, threshold: visibilityThreshold }
+ );
+ const resetSlidesObservers = initSlidesObservers(
+ container,
+ observer
+ );
+ const removeInterruptionEvents = initInterruptionEvents(
+ container,
+ resetSlidesObservers
+ );
+ const disconnectResizeObserver = initViewerResizeObserver(
+ container,
+ resetAlignment
+ );
+ resetAlignment();
+
+ return () => {
+ observer.disconnect();
+ removeInterruptionEvents();
+ pendingViewerImages.delete( container );
+ disconnectResizeObserver();
+ cancelAnimationFrame( frame );
+ };
+ },
/**
* Sync the gallery to the blockified Add to Cart + Options block's
* variation state. Bound via `data-wp-watch`, so it re-runs whenever
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
index a6b3c7335c4..3cf157bca46 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
@@ -16,16 +16,18 @@ $dialog-padding: 20px;
:where(.wc-block-product-gallery-large-image__container) {
display: flex;
- overflow: hidden;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none;
align-items: center;
margin: 0;
padding: 0;
- // Chrome drops `scrollTo({behavior:'smooth'})` on `overflow:hidden`
- // unless `scroll-behavior` is set on the element.
- scroll-behavior: smooth;
}
:where(.wc-block-product-gallery-large-image__wrapper) {
+ scroll-snap-align: start;
+ scroll-snap-stop: always;
flex-shrink: 0;
min-width: 100%;
max-width: 100%;
@@ -284,6 +286,10 @@ $dialog-padding: 20px;
outline-offset: -2px;
}
+:where(.wc-block-product-gallery-thumbnails__thumbnail__image):focus:not(:focus-visible) {
+ outline: none;
+}
+
:where(.wc-block-product-gallery-thumbnails__thumbnail__image--is-active) {
pointer-events: none;
cursor: default;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
index eed6ceaa8c0..5c822416cc4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
@@ -58,9 +58,6 @@ export interface ProductGalleryContext {
isDialogOpen: boolean;
videoLocation?: 'dialog' | 'gallery';
productId: string;
- touchStartX: number;
- touchCurrentX: number;
- isDragging: boolean;
imageData: number[];
thumbnailsOverflow: {
top: boolean;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
index dc0133ffb9d..c0cf46ca8ef 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
@@ -277,9 +277,6 @@ class ProductGallery extends AbstractBlock {
array(
'imageData' => $default_media_ids,
'isDialogOpen' => false,
- 'isDragging' => false,
- 'touchStartX' => 0,
- 'touchCurrentX' => 0,
'productId' => $product_id,
'selectedImageId' => $initial_media_id,
'thumbnailsOverflow' => [
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
index 7b7489b5b3f..1e174ffd4a0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
@@ -147,9 +147,6 @@ class ProductGalleryLargeImage extends AbstractBlock {
$p->set_attribute( 'draggable', 'false' );
$p->set_attribute( 'data-wp-watch', 'callbacks.toggleImageVisibility' );
$p->set_attribute( 'data-wp-on--click', 'actions.onViewerClick' );
- $p->set_attribute( 'data-wp-on--touchstart', 'actions.onTouchStart' );
- $p->set_attribute( 'data-wp-on--touchmove', 'actions.onTouchMove' );
- $p->set_attribute( 'data-wp-on--touchend', 'actions.onTouchEnd' );
if ( 0 === $index ) {
$p->set_attribute( 'fetchpriority', 'high' );
@@ -195,6 +192,7 @@ class ProductGalleryLargeImage extends AbstractBlock {
class="wc-block-product-gallery-large-image__container"
data-wp-interactive="woocommerce/product-gallery"
data-wp-on--keydown="actions.onViewerImageKeyDown"
+ data-wp-watch="callbacks.watchViewerScroll"
aria-label="<?php esc_attr_e( 'Product gallery', 'woocommerce' ); ?>"
tabindex="0"
aria-roledescription="carousel"
@@ -300,12 +298,9 @@ class ProductGalleryLargeImage extends AbstractBlock {
$attrs = array_merge(
$attrs,
array(
- 'class' => $video_classes,
- 'data-wp-on--touchend' => 'actions.onTouchEnd',
- 'data-wp-on--touchmove' => 'actions.onTouchMove',
- 'data-wp-on--touchstart' => 'actions.onTouchStart',
- 'draggable' => 'false',
- 'tabindex' => '-1',
+ 'class' => $video_classes,
+ 'draggable' => 'false',
+ 'tabindex' => '-1',
)
);
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
index 2ff5e15301e..bcff7952ebc 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
@@ -73,6 +73,7 @@ test.describe( `${ blockData.name }`, () => {
await expect( zoomWhileHoveringSetting ).toBeChecked();
} );
+
test( 'should work on frontend when is enabled', async ( {
pageObject,
editor,
@@ -126,6 +127,7 @@ test.describe( `${ blockData.name }`, () => {
expect( styleOnHover.transform ).toBe( '' );
} );
} );
+
test( 'should not work on frontend when is disabled', async ( {
pageObject,
editor,
@@ -294,14 +296,13 @@ test.describe( `${ blockData.name }`, () => {
} ).toPass( { timeout: 5_000 } );
} );
- test.describe( 'Swipe to navigate', () => {
- test.use( { hasTouch: true } ); // Enable touch support
+ test.describe( 'Frontend navigation', () => {
+ test.use( {
+ hasTouch: true,
+ contextOptions: { reducedMotion: 'no-preference' },
+ } );
- test( 'should work on frontend when is enabled', async ( {
- pageObject,
- editor,
- page,
- } ) => {
+ test.beforeEach( async ( { pageObject, editor, page } ) => {
await pageObject.addProductGalleryBlock( { cleanContent: true } );
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: true,
@@ -313,7 +314,12 @@ test.describe( `${ blockData.name }`, () => {
height: 667,
width: 390, // iPhone 12 Pro
} );
+ } );
+ test( 'updates thumbnail selection during a native swipe', async ( {
+ pageObject,
+ page,
+ } ) => {
const viewerBlock = await pageObject.getViewerBlock( {
page: 'frontend',
} );
@@ -321,64 +327,44 @@ test.describe( `${ blockData.name }`, () => {
const initialImageId = await pageObject.getViewerImageId();
- // Get the element's bounding box
- const box = await viewerImage.boundingBox();
- if ( ! box ) {
- return;
+ await viewerImage.scrollIntoViewIfNeeded();
+ const box = ( await viewerImage.boundingBox() )!;
+ expect( box ).not.toBeNull();
+
+ // DOM-dispatched touch events cannot trigger the browser's native scrolling.
+ const session = await page.context().newCDPSession( page );
+ const startX = box.x + box.width * 0.8;
+ const y = box.y + box.height / 2;
+ await session.send( 'Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [ { x: startX, y } ],
+ } );
+ for ( let step = 1; step <= 8; step++ ) {
+ await session.send( 'Input.dispatchTouchEvent', {
+ type: 'touchMove',
+ touchPoints: [
+ { x: startX - box.width * 0.075 * step, y },
+ ],
+ } );
}
- // Calculate start and end points for the swipe
- const swipeStartX = box.x + box.width / 2; // middle of element
- const swipeStartY = box.y + box.height / 2;
- const swipeEndX = swipeStartX - 200; // swipe left by 200px
- const swipeEndY = swipeStartY;
-
- // Dispatch touch events to simulate swipe
- await viewerImage.evaluate(
- ( element, { startX, startY, endX, endY } ) => {
- const touchStart = new TouchEvent( 'touchstart', {
- bubbles: true,
- cancelable: true,
- touches: [
- new Touch( {
- identifier: 0,
- target: element,
- clientX: startX,
- clientY: startY,
- } ),
- ],
- } );
-
- const touchMove = new TouchEvent( 'touchmove', {
- bubbles: true,
- cancelable: true,
- touches: [
- new Touch( {
- identifier: 0,
- target: element,
- clientX: endX,
- clientY: endY,
- } ),
- ],
- } );
-
- const touchEnd = new TouchEvent( 'touchend', {
- bubbles: true,
- cancelable: true,
- touches: [],
- } );
-
- element.dispatchEvent( touchStart );
- element.dispatchEvent( touchMove );
- element.dispatchEvent( touchEnd );
- },
- {
- startX: swipeStartX,
- startY: swipeStartY,
- endX: swipeEndX,
- endY: swipeEndY,
- }
+ const scroller = viewerBlock.locator(
+ '.wc-block-product-gallery-large-image__container'
);
+ await expect
+ .poll( () =>
+ scroller.evaluate( ( element ) => element.scrollLeft )
+ )
+ .toBeGreaterThan( 0 );
+ const imageIds = await pageObject.getVisibleViewerImageIds();
+ await expect
+ .poll( () => pageObject.getActiveThumbnailImageId() )
+ .toEqual( imageIds[ 1 ] );
+ await session.send( 'Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [],
+ } );
+ await session.detach();
// Verify dialog is not opened
const dialog = page.locator( '.wc-block-product-gallery-dialog' );
@@ -389,7 +375,18 @@ test.describe( `${ blockData.name }`, () => {
const nextImageId = await pageObject.getViewerImageId();
expect( nextImageId ).not.toEqual( initialImageId );
+ expect( await pageObject.getActiveThumbnailImageId() ).toEqual(
+ nextImageId
+ );
} ).toPass( { timeout: 1_000 } );
+
+ await expect
+ .poll( () =>
+ scroller.evaluate( ( element ) =>
+ Math.abs( element.scrollLeft - element.clientWidth )
+ )
+ )
+ .toBeLessThan( 1 );
} );
} );
} );