Commit 31798ea5448 for woocommerce
commit 31798ea5448f46377aeca00f1879a7af510446c3
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date: Tue May 19 00:50:22 2026 +0800
Add native variation gallery support (#64524)
Add Additional Variation Images functionality to Core
Brings native variation gallery support to WooCommerce, replacing the
retired Additional Variation Images extension. Each variation can have
its own ordered image set; the storefront swaps to it on selection.
This PR covers:
- the data model,
- migration from the legacy AVI extension,
- REST API integration,
- many different interaction cases between various versions of the blocks,
- backwards compatibility and classic theme support,
- packages-managed auto-deactivation of the old plugin,
- and some telemetry
The Admin UI is still left rough at this stage.
---------
Co-authored-by: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64524-feature-merge-additional-variation-images b/plugins/woocommerce/changelog/64524-feature-merge-additional-variation-images
new file mode 100644
index 00000000000..e29c159fb28
--- /dev/null
+++ b/plugins/woocommerce/changelog/64524-feature-merge-additional-variation-images
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add native variation gallery support: each variation can now have its own ordered image set, replacing the retired Additional Variation Images extension. Includes a migrator for legacy data and a runtime fallback so existing stores keep displaying their galleries.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts
new file mode 100644
index 00000000000..abb92f0898b
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts
@@ -0,0 +1,41 @@
+/**
+ * Centralized magic strings used across the Product Gallery frontend.
+ *
+ * Anything hardcoded that the gallery's runtime depends on — CSS class
+ * names, attribute-based selectors, jQuery event namespaces — lives here
+ * so call sites stay name-driven and refactors only touch one place.
+ */
+
+export const SELECTORS = {
+ galleryContainer: '.wp-block-woocommerce-product-gallery',
+ largeImageContainer: '.wc-block-product-gallery-large-image__container',
+ largeImageWrapper: '.wc-block-product-gallery-large-image__wrapper',
+ thumbnail: '.wc-block-product-gallery-thumbnails__thumbnail',
+ thumbnailsScrollable: '.wc-block-product-gallery-thumbnails__scrollable',
+ legacyVariationIdInput: 'input[name="variation_id"]',
+ legacyResetVariations: '.reset_variations',
+ imgByImageId: ( imageId: number | string ): string =>
+ `img[data-image-id="${ imageId }"]`,
+ elementByImageId: ( imageId: number | string ): string =>
+ `[data-image-id="${ imageId }"]`,
+ cartFormForProduct: ( productId: number | string ): string =>
+ `form[data-product_id="${ productId }"]`,
+} as const;
+
+export const CLASSES = {
+ activeThumbnail:
+ 'wc-block-product-gallery-thumbnails__thumbnail__image--is-active',
+ dialogOpenBody: 'wc-block-product-gallery-dialog-open',
+} as const;
+
+/**
+ * jQuery event names used by the legacy Add to Cart variation form.
+ * Suffixed with the `.wc-product-gallery` namespace so we can `.off()`
+ * everything we bound without disturbing handlers added by other code.
+ */
+export const LEGACY_FORM_JQUERY_EVENTS = {
+ namespace: '.wc-product-gallery',
+ foundVariation: 'found_variation.wc-product-gallery',
+ hideOrResetVariation:
+ 'hide_variation.wc-product-gallery reset_data.wc-product-gallery',
+} as const;
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 a9caf549cf6..a202407c805 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
@@ -7,6 +7,7 @@ import {
getElement,
withScope,
withSyncEvent,
+ getConfig,
} from '@wordpress/interactivity';
import '@woocommerce/stores/woocommerce/products';
import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
@@ -14,8 +15,14 @@ import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
/**
* Internal dependencies
*/
-import type { ProductGalleryContext } from './types';
+import type {
+ ProductGalleryContext,
+ ProductGalleryConfig,
+ ProductImageSet,
+} from './types';
import { checkOverflow } from './utils';
+import { subscribeLegacyJQueryFormVariations } from './legacy-jquery-form';
+import { SELECTORS, CLASSES } from './constants';
// Stores are locked to prevent 3PD usage until the API is stable.
const universalLock =
@@ -29,66 +36,180 @@ const getArrowsState = ( imageIndex: number, totalImages: number ) => ( {
isDisabledNext: imageIndex === totalImages - 1,
} );
+/** Read the `products` map from the WooCommerce iAPI config (or `{}`). */
+const getConfiguredProducts = () =>
+ ( getConfig( 'woocommerce' ) as ProductGalleryConfig )?.products || {};
+
+const getProductImageSet = ( productId: string | number ) =>
+ getConfiguredProducts()?.[ String( productId ) ];
+
/**
- * Scrolls the image into view for the main image.
- *
- * We use getElement to get the current element that triggered the action
- * to find the closest gallery container and scroll the image into view.
- * This is necessary because if you have two galleries on the same page with the same image IDs,
- * then we need to query the image in the correct gallery to avoid scrolling the wrong image into view.
- *
- * @param {string} imageId - The ID of the image to scroll into view.
+ * Find a variation by matching its featured `image_id` against the given
+ * `currentImageId`. Used as a fallback when the form doesn't expose a
+ * `variation_id` directly (early stages of legacy form lifecycle).
+ */
+const getVariationImageSetByCurrentImage = (
+ productImageSet: ProductImageSet,
+ currentImageId: number
+) =>
+ Object.values( productImageSet.variations || {} ).find(
+ ( variation ) => variation.image_id === currentImageId
+ );
+
+/**
+ * Pick the image to surface as selected after a visible-set change.
+ * Prefers the caller's request when it's still in the new set; otherwise
+ * falls back to the first image, or `-1` when the set is empty.
+ */
+const pickSelectedImageId = (
+ imageData: number[],
+ requestedId: number | undefined
+): number => {
+ if ( requestedId !== undefined && imageData.includes( requestedId ) ) {
+ return requestedId;
+ }
+ return imageData[ 0 ] ?? -1;
+};
+
+/** Recompute arrow disabled flags for an image set + selected slot. */
+const computeArrowsState = ( imageData: number[], selectedImageId: number ) => {
+ const index = imageData.indexOf( selectedImageId );
+ if ( index < 0 ) {
+ return { isDisabledPrevious: true, isDisabledNext: true };
+ }
+ 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'
+) => {
+ scrollImageIntoView( imageId, behavior );
+ scrollThumbnailIntoView( imageId );
+};
+
+/**
+ * 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.
*/
-const scrollImageIntoView = ( imageId: number ) => {
+const updateVisibleImageSet = (
+ imageIds: number[],
+ selectedImageId?: number
+) => {
+ const context = getContext();
+ const nextImageData = imageIds.length
+ ? imageIds
+ : getProductImageSet( context.productId )?.image_ids || [];
+ const nextSelectedImageId = pickSelectedImageId(
+ nextImageData,
+ selectedImageId
+ );
+ const arrowsState = computeArrowsState(
+ nextImageData,
+ nextSelectedImageId
+ );
+
+ context.imageData = nextImageData;
+ context.selectedImageId = nextSelectedImageId;
+ context.hideNextPreviousButtons = nextImageData.length <= 1;
+ context.isDisabledPrevious = arrowsState.isDisabledPrevious;
+ context.isDisabledNext = arrowsState.isDisabledNext;
+
+ if ( nextSelectedImageId === -1 ) {
+ return;
+ }
+
+ scrollImageEverywhereIntoView( nextSelectedImageId, 'instant' );
+};
+
+/**
+ * Toggle the `hidden` attribute on the closest gallery wrapper based on
+ * whether the element's `data-image-id` is in the current `imageData`.
+ * Bound via `data-wp-watch` so it re-runs reactively on context change.
+ */
+const toggleImageVisibility = ( element: HTMLElement ) => {
+ const imageIdValue = element.getAttribute( 'data-image-id' );
+ if ( ! imageIdValue ) {
+ return;
+ }
+
+ const imageId = Number.parseInt( imageIdValue, 10 );
+ const { imageData } = getContext();
+ const visibleIndex = imageData.indexOf( imageId );
+ const isVisible = visibleIndex >= 0;
+ const closestWrapper = element.closest(
+ `${ SELECTORS.largeImageWrapper }, ${ SELECTORS.thumbnail }`
+ ) as HTMLElement | null;
+ const visibilityTarget = closestWrapper || element;
+
+ visibilityTarget.hidden = ! isVisible;
+ visibilityTarget.style.order = isVisible ? String( visibleIndex ) : '';
+ element.setAttribute( 'aria-hidden', isVisible ? 'false' : 'true' );
+};
+
+/**
+ * Apply the active-thumbnail class and tabIndex when the element's
+ * image is both visible and the currently selected image; otherwise
+ * remove them.
+ */
+const toggleActiveThumbnailAttributes = ( element: HTMLElement ) => {
+ const imageIdValue = element.getAttribute( 'data-image-id' );
+ if ( ! imageIdValue ) {
+ return;
+ }
+
+ const { imageData, selectedImageId } = getContext();
+ const imageId = Number.parseInt( imageIdValue, 10 );
+ const isVisible = imageData.includes( imageId );
+
+ if ( isVisible && selectedImageId === imageId ) {
+ element.classList.add( CLASSES.activeThumbnail );
+ element.setAttribute( 'tabIndex', '0' );
+ return;
+ }
+
+ element.classList.remove( CLASSES.activeThumbnail );
+ element.setAttribute( 'tabIndex', '-1' );
+};
+
+const scrollImageIntoView = (
+ imageId: number,
+ behavior: ScrollBehavior = 'smooth'
+) => {
if ( ! imageId ) {
return;
}
- // Get the current element that triggered the action
const element = getElement()?.ref as HTMLElement;
-
if ( ! element ) {
return;
}
- const galleryContainer = element.closest(
- '.wp-block-woocommerce-product-gallery'
- );
-
+ const galleryContainer = element.closest( SELECTORS.galleryContainer );
if ( ! galleryContainer ) {
return;
}
- // Find the scrollable container for the viewer gallery
const scrollableContainer = galleryContainer.querySelector(
- '.wc-block-product-gallery-large-image__container'
- );
-
+ SELECTORS.largeImageContainer
+ ) as HTMLElement | null;
if ( ! scrollableContainer ) {
return;
}
- const imageElement = scrollableContainer.querySelector(
- `img[data-image-id="${ imageId }"]`
- );
-
- if ( imageElement ) {
- // Calculate the scroll position to center the image horizontally
- const containerRect = scrollableContainer.getBoundingClientRect();
- const imageRect = imageElement.getBoundingClientRect();
-
- const scrollLeft =
- scrollableContainer.scrollLeft +
- ( imageRect.left - containerRect.left ) -
- ( containerRect.width - imageRect.width ) / 2;
-
- // Use scrollTo as scrollIntoView with inline: 'center'
- // is not supported in iOS (Safari and Chrome).
- scrollableContainer.scrollTo( {
- left: scrollLeft,
- behavior: 'smooth',
- } );
+ const { imageData } = getContext();
+ const imageIndex = imageData.indexOf( imageId );
+ if ( imageIndex < 0 ) {
+ return;
}
+
+ scrollableContainer.scrollTo( {
+ left: imageIndex * scrollableContainer.clientWidth,
+ behavior,
+ } );
};
/**
@@ -109,16 +230,14 @@ const scrollThumbnailIntoView = ( imageId: number ) => {
}
// Find the closest gallery container
- const galleryContainer = element.closest(
- '.wp-block-woocommerce-product-gallery'
- );
+ const galleryContainer = element.closest( SELECTORS.galleryContainer );
if ( ! galleryContainer ) {
return;
}
const thumbnailElement = galleryContainer.querySelector(
- `.wc-block-product-gallery-thumbnails__thumbnail img[data-image-id="${ imageId }"]`
+ `${ SELECTORS.thumbnail } ${ SELECTORS.imgByImageId( imageId ) }`
);
if ( ! thumbnailElement ) {
@@ -127,16 +246,14 @@ const scrollThumbnailIntoView = ( imageId: number ) => {
// Find the thumbnail scrollable container
const scrollContainer = thumbnailElement.closest(
- '.wc-block-product-gallery-thumbnails__scrollable'
+ SELECTORS.thumbnailsScrollable
);
if ( ! scrollContainer ) {
return;
}
- const thumbnail = thumbnailElement.closest(
- '.wc-block-product-gallery-thumbnails__thumbnail'
- );
+ const thumbnail = thumbnailElement.closest( SELECTORS.thumbnail );
if ( ! thumbnail ) {
return;
@@ -170,6 +287,8 @@ const { state: productsState } = store< ProductsStore >(
{ lock: universalLock }
);
+const lastSeenVariationId = new Map< string, number | null | undefined >();
+
const productGallery = {
state: {
/**
@@ -186,6 +305,9 @@ const productGallery = {
selectImage: ( newImageIndex: number ) => {
const context = getContext();
const { imageData } = context;
+ if ( newImageIndex < 0 || newImageIndex >= imageData.length ) {
+ return;
+ }
const imageId = imageData[ newImageIndex ];
const { isDisabledPrevious, isDisabledNext } = getArrowsState(
@@ -202,6 +324,12 @@ const productGallery = {
scrollThumbnailIntoView( imageId );
}
},
+ setImageData: ( imageIds: number[], selectedImageId?: number ) => {
+ updateVisibleImageSet( imageIds, selectedImageId );
+ },
+ resetImageData: () => {
+ updateVisibleImageSet( [] );
+ },
selectCurrentImage: ( event?: MouseEvent ) => {
if ( event ) {
event.stopPropagation();
@@ -214,10 +342,27 @@ const productGallery = {
if ( ! imageIdValue ) {
return;
}
+
const imageId = parseInt( imageIdValue, 10 );
- const { imageData } = getContext();
- const newImageIndex = imageData.indexOf( imageId );
- actions.selectImage( newImageIndex );
+ 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;
+ }
+
+ scrollImageIntoView( imageId );
+ scrollThumbnailIntoView( imageId );
},
selectNextImage: ( event?: MouseEvent ) => {
if ( event ) {
@@ -312,16 +457,12 @@ const productGallery = {
event?.preventDefault();
const context = getContext();
context.isDialogOpen = true;
- document.body.classList.add(
- 'wc-block-product-gallery-dialog-open'
- );
+ document.body.classList.add( CLASSES.dialogOpenBody );
} ),
closeDialog: () => {
const context = getContext();
context.isDialogOpen = false;
- document.body.classList.remove(
- 'wc-block-product-gallery-dialog-open'
- );
+ document.body.classList.remove( CLASSES.dialogOpenBody );
},
onTouchStart: ( event: TouchEvent ) => {
const context = getContext();
@@ -399,11 +540,11 @@ const productGallery = {
if ( element ) {
const galleryContainer = element.closest(
- '.wp-block-woocommerce-product-gallery'
+ SELECTORS.galleryContainer
);
if ( galleryContainer ) {
const selectedImage = galleryContainer.querySelector(
- `img[data-image-id="${ selectedImageId }"]`
+ SELECTORS.imgByImageId( selectedImageId )
) as HTMLElement;
if ( selectedImage ) {
selectedImage.focus( { preventScroll: true } );
@@ -426,85 +567,158 @@ const productGallery = {
},
},
callbacks: {
+ /**
+ * Sync the gallery to the blockified Add to Cart + Options block's
+ * variation state. Bound via `data-wp-watch`, so it re-runs whenever
+ * `productsState.variationId` changes.
+ */
listenToProductDataChanges: () => {
- const product = productsState.productInContext;
+ const context = getContext();
+ const variationId = productsState.variationId;
+ const prevVariationId = lastSeenVariationId.get(
+ context.productId
+ );
+
+ if ( prevVariationId === variationId ) {
+ return;
+ }
+
+ if ( prevVariationId === undefined && ! variationId ) {
+ lastSeenVariationId.set( context.productId, variationId );
+ return;
+ }
+
+ lastSeenVariationId.set( context.productId, variationId );
+ const product = productsState.mainProductInContext;
if ( ! product ) {
return;
}
- const imageId = product.images?.[ 0 ]?.id;
- if ( ! imageId ) {
+ const productImageSet = getProductImageSet( product.id );
+ if ( ! productImageSet ) {
return;
}
- const { imageData } = getContext();
- const imageIndex = imageData.indexOf( imageId );
+ if ( ! variationId ) {
+ actions.resetImageData();
+ return;
+ }
- if ( imageIndex >= 0 ) {
- actions.selectImage( imageIndex );
+ const variationImageSet =
+ productImageSet.variations?.[ variationId ];
+
+ if ( variationImageSet?.image_ids?.length ) {
+ actions.setImageData(
+ variationImageSet.image_ids,
+ variationImageSet.image_id
+ );
+ return;
}
+
+ actions.resetImageData();
},
+ /**
+ * Subscribe the gallery to the legacy classic Add to Cart form's
+ * variation events. Prefers jQuery `found_variation` / `reset_data`
+ * when jQuery is present; falls back to a MutationObserver on the
+ * form's `current-image` attribute. Returns a teardown function.
+ */
watchForChangesOnAddToCartForm: () => {
const context = getContext();
- const variableProductCartForm = document.querySelector(
- `form[data-product_id="${ context.productId }"]`
- );
+ const $form = document.querySelector(
+ SELECTORS.cartFormForProduct( context.productId )
+ ) as HTMLElement | null;
- if ( ! variableProductCartForm ) {
+ if ( ! $form ) {
return;
}
- const selectFirstImage = () =>
- withScope( () => actions.selectImage( 0 ) );
+ const productImageSet = getProductImageSet( context.productId );
+ const syncFormVariationGallery = withScope( () => {
+ if ( ! productImageSet ) {
+ actions.resetImageData();
+ return;
+ }
+
+ const $variationIdInput = $form.querySelector(
+ SELECTORS.legacyVariationIdInput
+ ) as HTMLInputElement | null;
+ const hasVariationIdInput = !! $variationIdInput;
+ const currentVariationId = Number.parseInt(
+ $variationIdInput?.value || '0',
+ 10
+ );
+ // When the form exposes a variation_id input but it's empty,
+ // the merchant cleared the variation — restore the parent
+ // gallery instead of guessing from `current-image`.
+ if ( hasVariationIdInput && ! currentVariationId ) {
+ actions.resetImageData();
+ return;
+ }
+
+ const currentImageId = Number.parseInt(
+ $form.getAttribute( 'current-image' ) || '0',
+ 10
+ );
+ const variationImageSet = hasVariationIdInput
+ ? productImageSet.variations?.[ currentVariationId ]
+ : getVariationImageSetByCurrentImage(
+ productImageSet,
+ currentImageId
+ );
+
+ if ( variationImageSet?.image_ids?.length ) {
+ actions.setImageData(
+ variationImageSet.image_ids,
+ currentImageId || variationImageSet.image_id
+ );
+ return;
+ }
+
+ actions.resetImageData();
+ } );
+
+ const teardownJQuery = subscribeLegacyJQueryFormVariations( $form, {
+ onVariationFound: () => syncFormVariationGallery(),
+ onVariationReset: () => actions.resetImageData(),
+ } );
+
+ if ( teardownJQuery ) {
+ syncFormVariationGallery();
+ return teardownJQuery;
+ }
+
+ // MutationObserver fallback for environments without jQuery.
const observer = new MutationObserver(
- withScope( function ( mutations ) {
- for ( const mutation of mutations ) {
- const { imageData } = getContext();
-
- const mutationTarget = mutation.target as HTMLElement;
- const currentImageAttribute =
- mutationTarget.getAttribute( 'current-image' );
- const currentImageId = currentImageAttribute
- ? parseInt( currentImageAttribute, 10 )
- : null;
- if (
- mutation.type === 'attributes' &&
- currentImageId &&
- imageData.includes( currentImageId )
- ) {
- const nextImageIndex =
- imageData.indexOf( currentImageId );
-
- actions.selectImage( nextImageIndex );
- } else {
- actions.selectImage( 0 );
- }
- }
- } )
+ withScope( () => syncFormVariationGallery() )
);
+ const $clearVariationsLink = $form.querySelector(
+ SELECTORS.legacyResetVariations
+ );
+ const syncOnChange = withScope( () => syncFormVariationGallery() );
+ const resetGallery = withScope( () => actions.resetImageData() );
- observer.observe( variableProductCartForm, {
+ observer.observe( $form, {
attributes: true,
+ attributeFilter: [ 'current-image' ],
} );
+ $form.addEventListener( 'change', syncOnChange );
+ $clearVariationsLink?.addEventListener( 'click', resetGallery );
- const clearVariationsLink = document.querySelector(
- '.wp-block-add-to-cart-form .reset_variations'
- );
-
- if ( clearVariationsLink ) {
- clearVariationsLink.addEventListener(
- 'click',
- selectFirstImage
- );
- }
+ syncFormVariationGallery();
return () => {
observer.disconnect();
- document.removeEventListener( 'click', selectFirstImage );
+ $form.removeEventListener( 'change', syncOnChange );
+ $clearVariationsLink?.removeEventListener(
+ 'click',
+ resetGallery
+ );
};
},
+ /** When the dialog opens, focus it and center the active image vertically. */
dialogStateChange: () => {
const { selectedImageId, isDialogOpen } = getContext();
const { ref: dialogRef } = getElement() || {};
@@ -512,7 +726,7 @@ const productGallery = {
if ( isDialogOpen && dialogRef instanceof HTMLElement ) {
dialogRef.focus();
const selectedImage = dialogRef.querySelector(
- `[data-image-id="${ selectedImageId }"]`
+ SELECTORS.elementByImageId( selectedImageId )
);
if (
@@ -529,28 +743,26 @@ const productGallery = {
}
}
},
- toggleActiveThumbnailAttributes: () => {
+ /** Per-image `data-wp-watch` callback that toggles visibility from `imageData`. */
+ toggleImageVisibility: () => {
const element = getElement()?.ref as HTMLElement;
- if ( ! element ) return false;
-
- const imageIdValue = element.getAttribute( 'data-image-id' );
- if ( ! imageIdValue ) return false;
-
- const { selectedImageId } = getContext();
- const imageId = Number( imageIdValue );
+ if ( ! element ) {
+ return false;
+ }
- if ( selectedImageId === imageId ) {
- element.classList.add(
- 'wc-block-product-gallery-thumbnails__thumbnail__image--is-active'
- );
- element.setAttribute( 'tabIndex', '0' );
- } else {
- element.classList.remove(
- 'wc-block-product-gallery-thumbnails__thumbnail__image--is-active'
- );
- element.setAttribute( 'tabIndex', '-1' );
+ toggleImageVisibility( element );
+ },
+ /** Per-thumbnail callback that updates both visibility and the active-state class. */
+ syncThumbnailState: () => {
+ const element = getElement()?.ref as HTMLElement;
+ if ( ! element ) {
+ return false;
}
+
+ toggleImageVisibility( element );
+ toggleActiveThumbnailAttributes( element );
},
+ /** Set up a ResizeObserver on the thumbnails strip so overflow flags stay in sync. */
initResizeObserver: () => {
const scrollableElement = getElement()?.ref;
if ( ! scrollableElement ) {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts
new file mode 100644
index 00000000000..9c9097247ca
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts
@@ -0,0 +1,93 @@
+/**
+ * Legacy classic Add to Cart form integration.
+ *
+ * The classic single-product variation form (`add-to-cart-variation.js`)
+ * is jQuery-driven and emits its variation lifecycle as jQuery custom
+ * events. This module isolates that integration so the main gallery
+ * frontend stays free of jQuery type acrobatics.
+ *
+ * Returns a teardown callable when subscription succeeds, or `null` when
+ * jQuery isn't loaded on the page so the caller can fall back to a
+ * MutationObserver-based path.
+ */
+
+/**
+ * External dependencies
+ */
+import { withScope } from '@wordpress/interactivity';
+
+/**
+ * Internal dependencies
+ */
+import { LEGACY_FORM_JQUERY_EVENTS } from './constants';
+import type {
+ LegacyJQueryFormHandlers,
+ LegacyJQueryWindow,
+ LegacyVariationPayload,
+} from './types';
+
+/** A WP attachment ID that's safe to use as a gallery slot. */
+const isValidImageId = ( id: unknown ): id is number =>
+ typeof id === 'number' && Number.isInteger( id ) && id > 0;
+
+/**
+ * Coerce the variation event payload's IDs into a deduped list of
+ * positive integers, with the optional featured image at position 0.
+ */
+const normalizeImageData = (
+ imageIds: unknown,
+ featuredImageId?: number
+): number[] => {
+ const featured = isValidImageId( featuredImageId )
+ ? [ featuredImageId ]
+ : [];
+ const others = Array.isArray( imageIds )
+ ? imageIds
+ .map( ( id ) => Number.parseInt( String( id ), 10 ) )
+ .filter( isValidImageId )
+ : [];
+ return Array.from( new Set( [ ...featured, ...others ] ) );
+};
+
+/**
+ * Subscribe to the legacy classic Add to Cart form's jQuery variation
+ * events. Returns a teardown callable, or `null` when jQuery isn't
+ * loaded on the page.
+ */
+export const subscribeLegacyJQueryFormVariations = (
+ formElement: HTMLElement,
+ handlers: LegacyJQueryFormHandlers
+): null | ( () => void ) => {
+ const legacyJQuery = ( window as LegacyJQueryWindow ).jQuery;
+ if ( ! legacyJQuery ) {
+ return null;
+ }
+
+ const $form = legacyJQuery( formElement );
+
+ const handleFound = withScope(
+ ( _event?: unknown, variation?: LegacyVariationPayload ) => {
+ const imageData = normalizeImageData(
+ variation?.gallery_image_ids,
+ variation?.image_id
+ );
+
+ if ( imageData.length ) {
+ handlers.onVariationFound( imageData, variation?.image_id );
+ return;
+ }
+
+ handlers.onVariationReset();
+ }
+ );
+
+ const handleReset = withScope( () => handlers.onVariationReset() );
+
+ $form
+ .on( LEGACY_FORM_JQUERY_EVENTS.foundVariation, handleFound )
+ .on( LEGACY_FORM_JQUERY_EVENTS.hideOrResetVariation, handleReset );
+
+ return () => {
+ $form.off( LEGACY_FORM_JQUERY_EVENTS.namespace );
+ };
+};
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 b66c813f7ea..efcf228d2b2 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
@@ -21,6 +21,9 @@ $dialog-padding: 20px;
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) {
@@ -36,6 +39,10 @@ $dialog-padding: 20px;
}
}
+ .wc-block-product-gallery-large-image__wrapper[hidden] {
+ display: none;
+ }
+
// These rules are overriding styles from WooCommerce core, that's why we
// can't use `:where()` and they need a higher specificity.
.wc-block-components-product-image.wc-block-components-product-image {
@@ -246,6 +253,10 @@ $dialog-padding: 20px;
display: flex;
}
+.wc-block-product-gallery-thumbnails__thumbnail[hidden] {
+ display: none;
+}
+
:where(.wc-block-product-gallery-thumbnails__thumbnail__image) {
cursor: pointer;
max-width: 100%;
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 250134e1fcd..035c54276d3 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
@@ -1,3 +1,8 @@
+/**
+ * External dependencies
+ */
+import type { WooCommerceConfig } from '@woocommerce/stores/woocommerce/cart';
+
export interface ProductGalleryBlockAttributes {
hoverZoom: boolean;
fullScreenOnClick: boolean;
@@ -10,6 +15,41 @@ export interface ProductGallerySettingsProps {
) => void;
}
+export type VariationImageSet = {
+ image_id?: number;
+ image_ids?: number[];
+};
+
+export type ProductImageSet = VariationImageSet & {
+ variations?: Record< number, VariationImageSet >;
+};
+
+export type ProductGalleryConfig = WooCommerceConfig & {
+ products?: Record< string, ProductImageSet >;
+};
+
+export type LegacyVariationPayload = {
+ image_id?: number;
+ gallery_image_ids?: number[];
+};
+
+export type LegacyJQueryInstance = {
+ on: (
+ eventName: string,
+ handler: ( event?: unknown, variation?: LegacyVariationPayload ) => void
+ ) => LegacyJQueryInstance;
+ off: ( namespace: string ) => LegacyJQueryInstance;
+};
+
+export type LegacyJQueryWindow = Window & {
+ jQuery?: ( target: Element | string ) => LegacyJQueryInstance;
+};
+
+export type LegacyJQueryFormHandlers = {
+ onVariationFound: ( imageIds: number[], featuredImageId?: number ) => void;
+ onVariationReset: () => void;
+};
+
export interface ProductGalleryContext {
selectedImageId: number;
isDialogOpen: boolean;
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/bin/scripts/products.sh b/plugins/woocommerce/client/blocks/tests/e2e/bin/scripts/products.sh
index 16c2ea038b2..8ba81760616 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/bin/scripts/products.sh
+++ b/plugins/woocommerce/client/blocks/tests/e2e/bin/scripts/products.sh
@@ -34,6 +34,8 @@ wp post term set $hoodie_product_id product_brand $brand_id --by=id
wp post term set $beanie_product_id product_brand $brand_id --by=id
wp post term set $album_product_id product_brand $brand_id --by=id
+wp post meta update $beanie_product_id _product_image_gallery "$image1,$image2,$image3"
+
# This is a non-hacky work around to set up the cross sells product.
cap_product_id=$(wp post list --post_type=product --field=ID --name="Cap" --format=ids)
wp post meta update $beanie_product_id _crosssell_ids "$cap_product_id"
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
index a00e6b208ab..7082cd592e7 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
@@ -162,7 +162,7 @@ test.describe( `${ blockData.name }`, () => {
} );
} );
- test( 'Renders correct image when selecting a product variation in the Add to Cart with Options block', async ( {
+ test( 'Variable product gallery: featured → variation → cleared', async ( {
page,
editor,
pageObject,
@@ -173,7 +173,6 @@ test.describe( `${ blockData.name }`, () => {
const viewerBlock = await pageObject.getViewerBlock( {
page: 'editor',
} );
-
await expect( viewerBlock ).toBeVisible();
await editor.saveSiteEditorEntities( {
@@ -182,24 +181,31 @@ test.describe( `${ blockData.name }`, () => {
await page.goto( blockData.productPage );
- const initialImageId = await pageObject.getViewerImageId();
+ // Initial state: only the parent's featured image is visible.
+ const featuredImageId = await pageObject.getViewerImageId();
+ expect( featuredImageId ).not.toBeNull();
- const addToCartWithOptionsBlock =
- await pageObject.getAddToCartWithOptionsBlock( {
- page: 'frontend',
- } );
- const addToCartWithOptionsColorSelector =
- addToCartWithOptionsBlock.getByLabel( 'Color' );
- const addToCartWithOptionsSizeSelector =
- addToCartWithOptionsBlock.getByLabel( 'Logo' );
+ const cartForm = await pageObject.getAddToCartWithOptionsBlock( {
+ page: 'frontend',
+ } );
+ const colorSelect = cartForm.getByLabel( 'Color' );
+ const logoSelect = cartForm.getByLabel( 'Logo' );
- await addToCartWithOptionsColorSelector.selectOption( 'Green' );
- await addToCartWithOptionsSizeSelector.selectOption( 'No' );
+ // Variation selected: visible image switches to the variation's image.
+ await colorSelect.selectOption( 'Green' );
+ await logoSelect.selectOption( 'No' );
await expect( async () => {
const variationImageId = await pageObject.getViewerImageId();
+ expect( variationImageId ).not.toEqual( featuredImageId );
+ } ).toPass( { timeout: 5_000 } );
+
+ // Clear: gallery returns to the parent's featured image.
+ await colorSelect.selectOption( '' );
- expect( initialImageId ).not.toEqual( variationImageId );
+ await expect( async () => {
+ const afterClearImageId = await pageObject.getViewerImageId();
+ expect( afterClearImageId ).toEqual( featuredImageId );
} ).toPass( { timeout: 5_000 } );
} );
@@ -216,7 +222,7 @@ test.describe( `${ blockData.name }`, () => {
isOnlyCurrentEntityDirty: true,
} );
- await page.goto( blockData.productPage );
+ await page.goto( '/product/beanie/' );
await page.setViewportSize( {
height: 667,
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
index b1e30c28bd1..0b8305721a7 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
@@ -61,7 +61,7 @@ test.describe( 'Product Gallery Thumbnails block', () => {
} );
await test.step( 'in frontend', async () => {
- await page.goto( '/product/hoodie/' );
+ await page.goto( '/product/beanie/' );
const productGalleryBlock = page.locator(
'[data-block-name="woocommerce/product-gallery"]'
@@ -134,7 +134,7 @@ test.describe( 'Product Gallery Thumbnails block', () => {
} );
await test.step( 'in frontend', async () => {
- await page.goto( '/product/hoodie/' );
+ await page.goto( '/product/beanie/' );
const thumbnailsBlock = page.locator(
'[data-block-name="woocommerce/product-gallery-thumbnails"]'
@@ -192,7 +192,7 @@ test.describe( 'Product Gallery Thumbnails block', () => {
} );
await test.step( 'in frontend', async () => {
- await page.goto( '/product/hoodie/' );
+ await page.goto( '/product/beanie/' );
const thumbnailsContainer = page.locator(
'[data-block-name="woocommerce/product-gallery-thumbnails"]'
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/product-gallery.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/product-gallery.block_theme.spec.ts
index 37afb520fa7..75bcc4e2851 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/product-gallery.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/product-gallery.block_theme.spec.ts
@@ -13,7 +13,7 @@ const blockData = {
name: 'woocommerce/product-gallery',
title: 'Product Gallery',
slug: 'single-product',
- productPage: '/product/hoodie/',
+ productPage: '/product/beanie/',
};
const test = base.extend< { pageObject: ProductGalleryPage } >( {
diff --git a/plugins/woocommerce/client/legacy/css/variation-gallery-admin.scss b/plugins/woocommerce/client/legacy/css/variation-gallery-admin.scss
new file mode 100644
index 00000000000..28f686f6e82
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/css/variation-gallery-admin.scss
@@ -0,0 +1,421 @@
+/**
+ * Variation gallery (classic admin).
+ */
+
+/* === Local utility mixins ============================================= */
+
+/* Stretch a positioned child to fill its containing block. */
+@mixin fill-parent {
+ position: absolute;
+ inset: 0;
+}
+
+/* Flex container that centers a single child both axes. */
+@mixin center-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Make an <img> behave as a cover-fitted block of its parent's size. */
+@mixin cover-image {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+#variable_product_options
+ .woocommerce_variation:has(.wc-variation-gallery-field) {
+ /* === Design tokens ============================================== */
+
+ /* Colors (WP admin palette). */
+ --wc-vg-link: #2271b1;
+ --wc-vg-link-hover: #135e96;
+ --wc-vg-surface: #f0f0f1;
+ --wc-vg-surface-hover: #f6f7f7;
+ --wc-vg-surface-strong: #1d2327;
+ --wc-vg-surface-strongest: #000;
+ --wc-vg-on-surface: #fff;
+ --wc-vg-border: #dcdcde;
+ --wc-vg-border-dashed: #c3c4c7;
+ --wc-vg-border-hover: #8c8f94;
+ --wc-vg-text-strong: #1d2327;
+ --wc-vg-text-muted: #50575e;
+ --wc-vg-text-subtle: #787c82;
+ --wc-vg-text-placeholder: #a7aaad;
+ --wc-vg-focus-ring: rgba(34, 113, 177, 0.4);
+
+ /* Spacing scale (4px base). */
+ --wc-vg-space-1: 4px;
+ --wc-vg-space-2: 6px;
+ --wc-vg-space-3: 8px;
+ --wc-vg-space-4: 10px;
+ --wc-vg-space-5: 12px;
+ --wc-vg-space-6: 24px;
+
+ /* Sizing tokens. */
+ --wc-vg-thumb-size: 40px;
+ --wc-vg-icon-sm: 14px;
+ --wc-vg-icon-md: 20px;
+ --wc-vg-icon-lg: 28px;
+ --wc-vg-icon-xl: 42px;
+ --wc-vg-upload-col-width: 200px;
+
+ /* Radii (aliased onto the spacing scale). */
+ --wc-vg-radius-sm: var(--wc-vg-space-1);
+ --wc-vg-radius-md: var(--wc-vg-space-3);
+
+ /* Elevation. */
+ --wc-vg-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.12);
+ --wc-vg-elevation-2: 0 1px 2px rgba(0, 0, 0, 0.2);
+ --wc-vg-elevation-3-hover: 0 1px 4px rgba(0, 0, 0, 0.3),
+ 0 0 0 2px var(--wc-vg-focus-ring);
+
+ /* Typography. */
+ --wc-vg-font-size-xs: 12px;
+ --wc-vg-font-size-sm: 13px;
+ --wc-vg-font-weight-medium: 500;
+ --wc-vg-font-weight-semibold: 600;
+
+ /* Motion. */
+ --wc-vg-transition-fast: 120ms ease-out;
+
+ /* === Layout overrides for the gallery's flex row ================= */
+ /* Scoped to the specific .form-flex-box that contains the gallery
+ * field; other variation rows (pricing, weight, dimensions, etc.)
+ * use the same .form-flex-box class and must keep their own layout. */
+
+ .form-flex-box:has(.wc-variation-gallery-field) {
+ align-items: flex-start;
+ gap: var(--wc-vg-space-6);
+ justify-content: flex-start;
+
+ .form-row.upload_image {
+ width: var(--wc-vg-upload-col-width);
+ flex: 0 0 var(--wc-vg-upload-col-width);
+ margin: 0;
+ padding: 0;
+ }
+
+ .form-row.upload_image > a.upload_image_button {
+ display: none;
+ }
+
+ .form-row.form-row-last {
+ flex: 1 1 auto;
+ width: auto;
+ float: none;
+ margin: 0;
+ }
+ }
+
+ .wc-variation-gallery-field__thumbs .ui-sortable-helper {
+ pointer-events: none;
+ }
+}
+
+/* === Component ===================================================== */
+
+#variable_product_options .wc-variation-gallery-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wc-vg-space-4);
+ width: 100%;
+ box-sizing: border-box;
+
+ .wc-variation-gallery-field__header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--wc-vg-space-4);
+ }
+
+ .wc-variation-gallery-field__title-block {
+ display: flex;
+ align-items: baseline;
+ gap: var(--wc-vg-space-2);
+ flex-wrap: wrap;
+ }
+
+ .wc-variation-gallery-field__title {
+ font-size: var(--wc-vg-font-size-sm);
+ font-weight: var(--wc-vg-font-weight-semibold);
+ margin: 0;
+ }
+
+ .wc-variation-gallery-field__count {
+ color: var(--wc-vg-text-subtle);
+ font-size: var(--wc-vg-font-size-xs);
+
+ &::before {
+ content: "·";
+ margin-right: var(--wc-vg-space-1);
+ }
+ }
+
+ .wc-variation-gallery-manage {
+ color: var(--wc-vg-link);
+ background: transparent;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ font-size: var(--wc-vg-font-size-sm);
+ text-decoration: none;
+
+ &:hover,
+ &:focus {
+ color: var(--wc-vg-link-hover);
+ text-decoration: underline;
+ }
+ }
+
+ .wc-variation-gallery-field__hero {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ border-radius: var(--wc-vg-radius-md);
+ overflow: hidden;
+ background: var(--wc-vg-surface);
+ border: 1px solid var(--wc-vg-border);
+ }
+
+ .wc-variation-gallery-field__hero-img {
+ @include fill-parent;
+ @include cover-image;
+ }
+
+ .wc-variation-gallery-field__hero-broken {
+ @include fill-parent;
+ @include center-content;
+ color: var(--wc-vg-text-placeholder);
+
+ .dashicons {
+ font-size: var(--wc-vg-icon-xl);
+ width: var(--wc-vg-icon-xl);
+ height: var(--wc-vg-icon-xl);
+ }
+ }
+
+ .wc-variation-gallery-field__badge {
+ position: absolute;
+ top: var(--wc-vg-space-4);
+ right: var(--wc-vg-space-4);
+ display: inline-flex;
+ align-items: center;
+ gap: var(--wc-vg-space-1);
+ padding: var(--wc-vg-space-2) var(--wc-vg-space-4);
+ background: var(--wc-vg-on-surface);
+ border-radius: var(--wc-vg-radius-sm);
+ font-size: var(--wc-vg-font-size-xs);
+ font-weight: var(--wc-vg-font-weight-semibold);
+ color: var(--wc-vg-text-strong);
+ box-shadow: var(--wc-vg-elevation-1);
+
+ .dashicons {
+ font-size: var(--wc-vg-icon-sm);
+ width: var(--wc-vg-icon-sm);
+ height: var(--wc-vg-icon-sm);
+ line-height: 1;
+ color: var(--wc-vg-link);
+ }
+ }
+
+ .wc-variation-gallery-replace {
+ position: absolute;
+ bottom: var(--wc-vg-space-4);
+ right: var(--wc-vg-space-4);
+ background: var(--wc-vg-surface-strong);
+ color: var(--wc-vg-on-surface);
+ border: none;
+ border-radius: var(--wc-vg-radius-sm);
+ padding: var(--wc-vg-space-2) var(--wc-vg-space-5);
+ font-size: var(--wc-vg-font-size-xs);
+ font-weight: var(--wc-vg-font-weight-medium);
+ cursor: pointer;
+ box-shadow: var(--wc-vg-elevation-2);
+ min-height: 0;
+ line-height: 1.3;
+
+ &:hover,
+ &:focus {
+ background: var(--wc-vg-surface-strongest);
+ color: var(--wc-vg-on-surface);
+ box-shadow: var(--wc-vg-elevation-3-hover);
+ outline: none;
+ }
+ }
+
+ .wc-variation-gallery-field__empty-cta {
+ @include fill-parent;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--wc-vg-space-3);
+ background: transparent;
+ border: 2px dashed var(--wc-vg-border-dashed);
+ border-radius: var(--wc-vg-radius-md);
+ color: var(--wc-vg-text-muted);
+ cursor: pointer;
+ font-size: var(--wc-vg-font-size-sm);
+ transition: background-color var(--wc-vg-transition-fast),
+ border-color var(--wc-vg-transition-fast);
+
+ &:hover,
+ &:focus {
+ background: var(--wc-vg-surface-hover);
+ border-color: var(--wc-vg-link);
+ color: var(--wc-vg-link);
+ outline: none;
+ }
+
+ .dashicons {
+ font-size: var(--wc-vg-icon-lg);
+ width: var(--wc-vg-icon-lg);
+ height: var(--wc-vg-icon-lg);
+ line-height: 1;
+ }
+ }
+
+ .wc-variation-gallery-field__thumbs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--wc-vg-space-3);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ min-height: 0;
+
+ .wc-variation-gallery-thumb,
+ .wc-metabox-sortable-placeholder {
+ width: var(--wc-vg-thumb-size);
+ height: var(--wc-vg-thumb-size);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ border-radius: var(--wc-vg-radius-sm);
+ box-sizing: border-box;
+ }
+
+ .wc-variation-gallery-thumb {
+ position: relative;
+ border: 2px solid transparent;
+ cursor: grab;
+ transition: border-color var(--wc-vg-transition-fast),
+ box-shadow var(--wc-vg-transition-fast);
+
+ &.is-active {
+ border-color: var(--wc-vg-link);
+ }
+
+ &:not(.is-active):hover {
+ border-color: var(--wc-vg-border-hover);
+ }
+
+ &.is-dragging {
+ opacity: 0.6;
+ cursor: grabbing;
+
+ .wc-variation-gallery-thumb__remove {
+ opacity: 0;
+ pointer-events: none;
+ }
+ }
+
+ &.is-broken {
+ @include center-content;
+ background: var(--wc-vg-surface);
+ color: var(--wc-vg-text-placeholder);
+ }
+
+ &:hover .wc-variation-gallery-thumb__remove,
+ .wc-variation-gallery-thumb__remove:focus-visible {
+ opacity: 1;
+ }
+ }
+
+ .wc-variation-gallery-thumb__button {
+ display: block;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ background: var(--wc-vg-surface);
+ cursor: inherit;
+
+ &:focus {
+ outline: none;
+ }
+
+ img {
+ @include cover-image;
+ }
+ }
+
+ .wc-variation-gallery-thumb__broken .dashicons {
+ font-size: var(--wc-vg-icon-md);
+ width: var(--wc-vg-icon-md);
+ height: var(--wc-vg-icon-md);
+ }
+
+ .wc-variation-gallery-thumb__remove {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--wc-vg-surface-strong);
+ color: var(--wc-vg-on-surface);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--wc-vg-transition-fast), background-color var(--wc-vg-transition-fast);
+
+ .dashicons {
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+ line-height: 1;
+ }
+
+ @media (hover: none) {
+ opacity: 1;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
+ }
+
+ .wc-metabox-sortable-placeholder {
+ border: 2px dashed var(--wc-vg-border-dashed);
+ background: transparent;
+ }
+
+ &.is-sorting .wc-variation-gallery-thumb:hover .wc-variation-gallery-thumb__remove,
+ &.is-sorting .wc-variation-gallery-thumb .wc-variation-gallery-thumb__remove {
+ opacity: 0;
+ pointer-events: none;
+ }
+ }
+
+ &.is-empty .wc-variation-gallery-field__thumbs {
+ display: none;
+ }
+
+ .wc-variation-gallery-field__hint {
+ margin: 0;
+ color: var(--wc-vg-text-muted);
+ font-size: var(--wc-vg-font-size-xs);
+ line-height: 1.4;
+ }
+
+ &.is-empty .wc-variation-gallery-field__hint {
+ display: none;
+ }
+}
diff --git a/plugins/woocommerce/client/legacy/js/admin/variation-gallery.js b/plugins/woocommerce/client/legacy/js/admin/variation-gallery.js
new file mode 100644
index 00000000000..a171b89bbad
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/admin/variation-gallery.js
@@ -0,0 +1,947 @@
+/* global jQuery, wp, wcVariationGalleryL10n */
+
+/*
+ * Variation gallery (classic admin).
+ *
+ * Embedded inside the variation meta box on the classic Edit Product screen.
+ */
+
+jQuery( function ( $ ) {
+ 'use strict';
+
+ const SELECTORS = {
+ productOptionsRoot: '#variable_product_options',
+ productData: '#woocommerce-product-data',
+ field: '.wc-variation-gallery-field',
+ fieldThumbList: '.wc-variation-gallery-field__thumbs',
+ fieldHero: '.wc-variation-gallery-field__hero',
+ fieldHeroImg: '.wc-variation-gallery-field__hero-img',
+ fieldHeroBroken: '.wc-variation-gallery-field__hero-broken',
+ fieldHeroEmptyCta: '.wc-variation-gallery-field__empty-cta',
+ fieldHint: '.wc-variation-gallery-field__hint',
+ fieldCount: '.wc-variation-gallery-field__count',
+ fieldImageIdsInput: '.wc-variation-gallery-image-ids',
+ thumb: '.wc-variation-gallery-thumb',
+ thumbButton: '.wc-variation-gallery-thumb__button',
+ thumbRemove: '.wc-variation-gallery-thumb__remove',
+ manageTrigger: '.wc-variation-gallery-manage',
+ replaceTrigger: '.wc-variation-gallery-replace',
+ primaryBadge: '[data-primary-badge]',
+ missingFileLabel: '.screen-reader-text[data-missing-file-label]',
+ // Legacy variation-row fields that we keep in sync.
+ variationRow: '.woocommerce_variation',
+ legacyInput: '.upload_image_id',
+ legacyButton: '.upload_image_button',
+ };
+
+ const CLASSES = {
+ isEmpty: 'is-empty',
+ isActive: 'is-active',
+ isBroken: 'is-broken',
+ fieldHeroImg: 'wc-variation-gallery-field__hero-img',
+ fieldHeroBroken: 'wc-variation-gallery-field__hero-broken',
+ };
+
+ const l10n = window.wcVariationGalleryL10n || {};
+ const a11y = ( wp && wp.a11y ) || null;
+
+ /**
+ * Speak a message via wp.a11y.speak when available. No-op otherwise.
+ *
+ * @param {string} message
+ */
+ const announce = ( message ) => {
+ if ( message && a11y && typeof a11y.speak === 'function' ) {
+ a11y.speak( message );
+ }
+ };
+
+ /**
+ * Pick the URL of the largest reasonable preview for the hero slot.
+ * Prefers `medium`, then `full`, then the raw attachment URL.
+ *
+ * @param {{ sizes?: Object, url?: string }} attachmentJson
+ * @return {string}
+ */
+ const pickAttachmentDisplayUrl = ( attachmentJson ) => {
+ const sizes = attachmentJson.sizes || {};
+ const preferred = sizes.medium || sizes.full;
+ return ( preferred && preferred.url ) || attachmentJson.url || '';
+ };
+
+ /**
+ * Pick the thumbnail URL for use in small previews (gallery thumbs and
+ * the legacy inline preview slot). Falls back to the raw URL when no
+ * thumbnail variant is registered for this attachment.
+ *
+ * @param {{ sizes?: Object, url?: string }} attachmentJson
+ * @return {string}
+ */
+ const pickAttachmentThumbnailUrl = ( attachmentJson ) => {
+ const sizes = attachmentJson.sizes || {};
+ return (
+ ( sizes.thumbnail && sizes.thumbnail.url ) ||
+ attachmentJson.url ||
+ ''
+ );
+ };
+
+ /**
+ * Map an image count to the i18n template key used to label the field.
+ *
+ * @param {number} count
+ * @return {string}
+ */
+ const getCountTemplateKey = ( count ) => {
+ if ( count === 0 ) {
+ return 'countZero';
+ }
+ if ( count === 1 ) {
+ return 'countSingular';
+ }
+ return 'countPlural';
+ };
+
+ /**
+ * Remove all hero-state overlays.
+ *
+ * @param {jQuery} $hero
+ */
+ const clearHeroOverlays = ( $hero ) => {
+ $hero.find( SELECTORS.fieldHeroEmptyCta ).remove();
+ $hero.find( SELECTORS.fieldHeroBroken ).remove();
+ $hero.find( SELECTORS.missingFileLabel ).remove();
+ };
+
+ /**
+ * Return the existing hero <img> if one is present, or create and
+ * prepend a fresh one.
+ *
+ * @param {jQuery} $hero
+ * @return {jQuery}
+ */
+ const getOrCreateHeroImage = ( $hero ) => {
+ const $existing = $hero.find( SELECTORS.fieldHeroImg );
+ if ( $existing.length ) {
+ return $existing;
+ }
+
+ const $img = $( '<img />' )
+ .addClass( CLASSES.fieldHeroImg )
+ .attr( 'loading', 'lazy' )
+ .attr( 'decoding', 'async' );
+ $hero.prepend( $img );
+ return $img;
+ };
+
+ const variationGallery = {
+ /** @type {wp.media.frames.MediaFrame|null} */
+ manageFrame: null,
+ /** @type {wp.media.frames.MediaFrame|null} */
+ replaceFrame: null,
+ /** @type {jQuery|null} */
+ activeField: null,
+ /** @type {number[]} */
+ activePreloadIds: [],
+ /** @type {number|null} */
+ activeIndexForReplace: null,
+ wpMediaPostId: wp.media.model.settings.post.id,
+
+ init() {
+ const $root = $( SELECTORS.productOptionsRoot );
+
+ $root.on(
+ 'click',
+ SELECTORS.manageTrigger,
+ this.onManage.bind( this )
+ );
+ $root.on(
+ 'click',
+ SELECTORS.replaceTrigger,
+ this.onReplace.bind( this )
+ );
+ $root.on(
+ 'click',
+ SELECTORS.thumbButton,
+ this.onThumbClick.bind( this )
+ );
+ $root.on(
+ 'click',
+ SELECTORS.thumbRemove,
+ this.onRemoveClick.bind( this )
+ );
+
+ // The meta box re-fires these events when variation rows are paginated
+ // in or appended after a save, so re-initialize sortables each time.
+ $root.on(
+ 'woocommerce_variations_added',
+ this.initializeSortables.bind( this )
+ );
+ $( SELECTORS.productData ).on(
+ 'woocommerce_variations_loaded',
+ this.initializeSortables.bind( this )
+ );
+
+ this.initializeSortables();
+ },
+
+ initializeSortables() {
+ $( SELECTORS.fieldThumbList ).each( function () {
+ const $list = $( this );
+ const $field = $list.closest( SELECTORS.field );
+
+ variationGallery.updateFromDom( $field );
+
+ if ( $list.data( 'wc-variation-gallery-sortable' ) ) {
+ return;
+ }
+
+ $list.sortable( {
+ items: 'li' + SELECTORS.thumb,
+ cancel: SELECTORS.thumbRemove,
+ cursor: 'grabbing',
+ scrollSensitivity: 40,
+ forcePlaceholderSize: true,
+ helper: 'clone',
+ opacity: 0.65,
+ placeholder: 'wc-metabox-sortable-placeholder',
+ start( _event, ui ) {
+ ui.item.addClass( 'is-dragging' );
+ $list.addClass( 'is-sorting' );
+ },
+ stop( _event, ui ) {
+ ui.item.removeClass( 'is-dragging' );
+ $list.removeClass( 'is-sorting' );
+ },
+ update() {
+ const wasPrimary =
+ variationGallery.getActiveAttachmentId( $field );
+ variationGallery.setActiveIndex( $field, 0 );
+ variationGallery.syncField( $field );
+
+ const isPrimary =
+ variationGallery.getActiveAttachmentId( $field );
+ announce(
+ wasPrimary !== isPrimary
+ ? l10n.announcePrimary
+ : l10n.announceReorder
+ );
+ },
+ } );
+
+ $list.data( 'wc-variation-gallery-sortable', true );
+ } );
+ },
+
+ /**
+ * Click on a thumbnail: surface that image in the hero slot.
+ *
+ * Does not change the gallery order or the primary image.
+ *
+ * @param {jQuery.Event} event
+ */
+ onThumbClick( event ) {
+ const $button = $( event.currentTarget );
+ const $thumb = $button.closest( SELECTORS.thumb );
+ const $field = $thumb.closest( SELECTORS.field );
+ const index = $thumb.index();
+
+ event.preventDefault();
+ this.setActiveIndex( $field, index );
+ },
+
+ /**
+ * Click on a thumbnail's remove button: drop that image from the
+ * gallery.
+ *
+ * @param {jQuery.Event} event
+ */
+ onRemoveClick( event ) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const $trigger = $( event.currentTarget );
+ const $thumb = $trigger.closest( SELECTORS.thumb );
+ const $field = $thumb.closest( SELECTORS.field );
+ const removedIndex = $thumb.index();
+ const currentActive = this.getActiveIndex( $field );
+ const ids = this.getFieldIds( $field );
+
+ ids.splice( removedIndex, 1 );
+
+ let nextActive;
+ if ( removedIndex < currentActive ) {
+ nextActive = currentActive - 1;
+ } else if ( removedIndex === currentActive ) {
+ nextActive = Math.min( removedIndex, ids.length - 1 );
+ } else {
+ nextActive = currentActive;
+ }
+
+ this.writeGallery( $field, ids, Math.max( 0, nextActive ) );
+ announce( l10n.announceRemoved );
+ },
+
+ /**
+ * "Manage" button: open the WP media frame in multi-select mode,
+ * preselect the variation's current gallery, and rewrite the
+ * gallery from whatever the merchant selects.
+ *
+ * @param {jQuery.Event} event
+ */
+ onManage( event ) {
+ const $trigger = $( event.currentTarget );
+ const $field = $trigger.closest( SELECTORS.field );
+ const variationId = $field.data( 'variationId' );
+
+ event.preventDefault();
+
+ // Scope newly-uploaded attachments to this variation post.
+ wp.media.model.settings.post.id = variationId;
+
+ // Update per-open state read by the cached frame's handlers.
+ this.activeField = $field;
+ this.activePreloadIds = this.getFieldIds( $field );
+
+ if ( ! this.manageFrame ) {
+ this.manageFrame = wp.media( {
+ title: l10n.manageTitle,
+ library: { type: 'image' },
+ button: { text: l10n.manageButton },
+ multiple: 'add',
+ } );
+
+ this.manageFrame.on( 'open', () =>
+ this.preloadFrameSelection(
+ this.manageFrame,
+ this.activePreloadIds
+ )
+ );
+ this.manageFrame.on( 'select', () =>
+ this.onManageSelect( this.manageFrame, this.activeField )
+ );
+ this.manageFrame.on( 'close', () => this.restoreMediaPostId() );
+ }
+
+ this.manageFrame.open();
+ },
+
+ /**
+ * "Replace" button on the hero slot: open the WP media frame in
+ * single-select mode and swap the currently-active gallery slot
+ * with the chosen attachment.
+ *
+ * @param {jQuery.Event} event
+ */
+ onReplace( event ) {
+ const $trigger = $( event.currentTarget );
+ const $field = $trigger.closest( SELECTORS.field );
+ const variationId = $field.data( 'variationId' );
+
+ event.preventDefault();
+
+ wp.media.model.settings.post.id = variationId;
+
+ this.activeField = $field;
+ this.activeIndexForReplace = this.getActiveIndex( $field );
+
+ if ( ! this.replaceFrame ) {
+ this.replaceFrame = wp.media( {
+ title: l10n.replaceTitle,
+ library: { type: 'image' },
+ button: { text: l10n.replaceButton },
+ multiple: false,
+ } );
+
+ this.replaceFrame.on( 'select', () =>
+ this.onReplaceSelect( this.replaceFrame, this.activeField )
+ );
+ this.replaceFrame.on( 'close', () =>
+ this.restoreMediaPostId()
+ );
+ }
+
+ this.replaceFrame.open();
+ },
+
+ /**
+ * When the manage frame opens, populate its selection with the
+ * variation's current gallery.
+ *
+ * @param {wp.media.frames.MediaFrame} frame
+ * @param {number[]} currentIds
+ */
+ preloadFrameSelection( frame, currentIds ) {
+ if ( ! frame ) {
+ return;
+ }
+
+ const selection = frame.state().get( 'selection' );
+ if ( ! selection ) {
+ return;
+ }
+
+ selection.reset();
+
+ currentIds.forEach( ( id ) => {
+ const attachment = wp.media.attachment( id );
+ attachment.fetch();
+ selection.add( attachment );
+ } );
+ },
+
+ /**
+ * Manage frame "select" handler: read attachments out of the
+ * frame's selection model and rewrite the gallery to match.
+ *
+ * @param {wp.media.frames.MediaFrame} frame
+ * @param {jQuery} $field
+ */
+ onManageSelect( frame, $field ) {
+ const selection = frame.state().get( 'selection' );
+ const nextIds = [];
+
+ selection.each( ( attachment ) => {
+ const json = attachment.toJSON();
+ if ( json.id ) {
+ nextIds.push( Number( json.id ) );
+ }
+ } );
+
+ this.writeGallery( $field, nextIds );
+ announce( l10n.announceUpdated );
+ this.restoreMediaPostId();
+ },
+
+ /**
+ * Replace frame "select" handler: swap the attachment at the
+ * cached active index with the chosen one and keep that slot
+ * surfaced as the hero image.
+ *
+ * @param {wp.media.frames.MediaFrame} frame
+ * @param {jQuery} $field
+ */
+ onReplaceSelect( frame, $field ) {
+ const selection = frame.state().get( 'selection' );
+ const attachment = selection.first();
+
+ if ( ! attachment ) {
+ return;
+ }
+
+ const newId = Number( attachment.toJSON().id );
+ const index = this.activeIndexForReplace;
+ const ids = this.getFieldIds( $field );
+
+ if ( index === null || index < 0 || index >= ids.length ) {
+ return;
+ }
+
+ ids[ index ] = newId;
+ this.writeGallery( $field, ids, index );
+ announce( l10n.announceReplaced );
+ this.restoreMediaPostId();
+ },
+
+ /**
+ * Replace the field's gallery with the given ID list, dedupe,
+ * re-render thumbs, surface the active slot, and sync the field.
+ *
+ * @param {jQuery} $field
+ * @param {number[]} nextIds
+ * @param {number} [activeIndex=0]
+ */
+ writeGallery( $field, nextIds, activeIndex = 0 ) {
+ const uniqueIds = Array.from( new Set( nextIds ) ).filter(
+ ( id ) => Number.isInteger( id ) && id > 0
+ );
+
+ this.rebuildThumbs( $field, uniqueIds );
+ this.setActiveIndex(
+ $field,
+ Math.min( activeIndex, Math.max( uniqueIds.length - 1, 0 ) )
+ );
+ this.syncField( $field );
+ },
+
+ /**
+ * Read the cached thumbnail/hero `src` for an attachment from the
+ * existing field DOM. Server-rendered <img> tags carry valid URLs
+ * even for attachments that haven't been loaded into wp.media's
+ * client cache (e.g. migrator-imported images), so prefer the DOM
+ * over `wp.media.attachment(id).attributes`.
+ *
+ * @param {jQuery} $field
+ * @param {number} id
+ * @return {string}
+ */
+ getCachedAttachmentUrl( $field, id ) {
+ const $existingThumb = $field.find(
+ SELECTORS.thumb +
+ '[data-attachment_id="' +
+ id +
+ '"] ' +
+ SELECTORS.thumbButton +
+ ' img'
+ );
+
+ if ( $existingThumb.length ) {
+ return $existingThumb.attr( 'src' ) || '';
+ }
+
+ const $existingHero = $field.find(
+ SELECTORS.fieldHeroImg + '[data-id="' + id + '"]'
+ );
+
+ if ( $existingHero.length ) {
+ return $existingHero.attr( 'src' ) || '';
+ }
+
+ return '';
+ },
+
+ /**
+ * Resolve a usable URL for the given attachment, preferring the
+ * server-rendered DOM and falling back to the media-frame cache
+ * (which is hydrated for attachments the merchant just selected
+ * via wp.media).
+ *
+ * @param {jQuery} $field
+ * @param {number} id
+ * @param {(json: Object) => string} pick
+ * @return {string}
+ */
+ resolveAttachmentUrl( $field, id, pick ) {
+ const cached = this.getCachedAttachmentUrl( $field, id );
+ if ( cached ) {
+ return cached;
+ }
+
+ const attachment = wp.media.attachment( id );
+ return pick( attachment.attributes || {} );
+ },
+
+ /**
+ * Re-render the thumbnail list from scratch for the given IDs.
+ * Caller is responsible for ensuring the IDs are unique and
+ * non-empty before this is invoked.
+ *
+ * @param {jQuery} $field
+ * @param {number[]} ids
+ */
+ rebuildThumbs( $field, ids ) {
+ const $list = $field.find( SELECTORS.fieldThumbList );
+ const urls = ids.map( ( id ) =>
+ this.resolveAttachmentUrl(
+ $field,
+ id,
+ pickAttachmentThumbnailUrl
+ )
+ );
+
+ $list.empty();
+
+ ids.forEach( ( id, index ) => {
+ $list.append(
+ this.buildThumbMarkup( id, urls[ index ], index === 0 )
+ );
+ } );
+
+ if ( $list.data( 'wc-variation-gallery-sortable' ) ) {
+ $list.sortable( 'refresh' );
+ }
+ },
+
+ /**
+ * Build the markup for a single thumbnail list item.
+ *
+ * Renders a "missing file" placeholder when no thumbnail
+ * URL is available.
+ *
+ * @param {number} id
+ * @param {string} thumbnailUrl
+ * @param {boolean} isActive
+ * @return {jQuery}
+ */
+ buildThumbMarkup( id, thumbnailUrl, isActive ) {
+ const labelTemplate = l10n.thumbLabel || 'Show gallery image %d';
+ const label = labelTemplate.replace( '%d', id );
+ const $li = $( '<li></li>' )
+ .addClass( 'wc-variation-gallery-thumb' )
+ .toggleClass( CLASSES.isActive, isActive )
+ .attr( 'data-attachment_id', id );
+ const $button = $( '<button type="button"></button>' )
+ .addClass( 'wc-variation-gallery-thumb__button' )
+ .attr( 'aria-label', label );
+ const $remove = $( '<button type="button"></button>' )
+ .addClass( 'wc-variation-gallery-thumb__remove' )
+ .attr( 'aria-label', l10n.removeLabel || 'Remove image' )
+ .append(
+ $( '<span></span>' )
+ .addClass( 'dashicons dashicons-no-alt' )
+ .attr( 'aria-hidden', 'true' )
+ );
+
+ if ( thumbnailUrl ) {
+ const $img = $( '<img />' )
+ .attr( 'src', thumbnailUrl )
+ .attr( 'alt', '' );
+ $button.append( $img );
+ return $li.append( $button, $remove );
+ }
+
+ $li.addClass( CLASSES.isBroken );
+
+ const $brokenIcon = $( '<span></span>' ).addClass(
+ 'dashicons dashicons-format-image'
+ );
+ const $brokenWrapper = $( '<span></span>' )
+ .addClass( 'wc-variation-gallery-thumb__broken' )
+ .attr( 'aria-hidden', 'true' )
+ .append( $brokenIcon );
+ const $srLabel = $( '<span></span>' )
+ .addClass( 'screen-reader-text' )
+ .text( l10n.missingFileLabel || 'Attachment file missing' );
+
+ $button.append( $brokenWrapper, $srLabel );
+ return $li.append( $button, $remove );
+ },
+
+ /**
+ * Surface the slot at `index` in the hero area and mark its thumb
+ * as active. Falls back to the empty state if there are no images.
+ *
+ * @param {jQuery} $field
+ * @param {number} index
+ */
+ setActiveIndex( $field, index ) {
+ const ids = this.getFieldIds( $field );
+
+ if ( ! ids.length ) {
+ this.setHeroEmpty( $field );
+ return;
+ }
+
+ const safeIndex = Math.max( 0, Math.min( index, ids.length - 1 ) );
+ const activeId = ids[ safeIndex ];
+
+ $field
+ .find( SELECTORS.fieldHero )
+ .attr( 'data-active-index', safeIndex );
+
+ this.setHeroImage( $field, activeId, safeIndex === 0 );
+
+ $field.find( SELECTORS.thumb ).removeClass( CLASSES.isActive );
+ $field
+ .find(
+ SELECTORS.thumb + '[data-attachment_id="' + activeId + '"]'
+ )
+ .addClass( CLASSES.isActive );
+ },
+
+ /**
+ * @param {jQuery} $field
+ * @return {number}
+ */
+ getActiveIndex( $field ) {
+ return Number(
+ $field
+ .find( SELECTORS.fieldHero )
+ .attr( 'data-active-index' ) || 0
+ );
+ },
+
+ /**
+ * @param {jQuery} $field
+ * @return {number}
+ */
+ getActiveAttachmentId( $field ) {
+ const ids = this.getFieldIds( $field );
+
+ if ( ! ids.length ) {
+ return 0;
+ }
+
+ return ids[ this.getActiveIndex( $field ) ] || ids[ 0 ];
+ },
+
+ /**
+ * Render the given attachment in the hero slot. Falls through to
+ * the missing-file state if the attachment record has no usable
+ * URL (e.g. the underlying file has been deleted).
+ *
+ * @param {jQuery} $field
+ * @param {number} attachmentId
+ * @param {boolean} isPrimary
+ */
+ setHeroImage( $field, attachmentId, isPrimary ) {
+ const $hero = $field.find( SELECTORS.fieldHero );
+ const url = this.resolveAttachmentUrl(
+ $field,
+ attachmentId,
+ pickAttachmentDisplayUrl
+ );
+
+ if ( ! url ) {
+ this.setHeroMissingFile( $field, isPrimary );
+ return;
+ }
+
+ clearHeroOverlays( $hero );
+
+ const $img = getOrCreateHeroImage( $hero );
+ $img.attr( 'src', url )
+ .attr( 'data-id', attachmentId )
+ .attr( 'alt', '' );
+
+ this.ensureHeroControls( $hero, isPrimary );
+ },
+
+ /**
+ * Render the "attachment file is missing" placeholder in the hero
+ * slot. Used when an attachment row exists but the underlying file
+ * has been deleted or is otherwise unreachable.
+ *
+ * @param {jQuery} $field
+ * @param {boolean} isPrimary
+ */
+ setHeroMissingFile( $field, isPrimary ) {
+ const $hero = $field.find( SELECTORS.fieldHero );
+
+ $hero.find( SELECTORS.fieldHeroEmptyCta ).remove();
+ $hero.find( SELECTORS.fieldHeroImg ).remove();
+
+ if ( ! $hero.find( SELECTORS.fieldHeroBroken ).length ) {
+ const $brokenIcon = $( '<span></span>' ).addClass(
+ 'dashicons dashicons-format-image'
+ );
+ const $brokenWrapper = $( '<span></span>' )
+ .addClass( CLASSES.fieldHeroBroken )
+ .attr( 'aria-hidden', 'true' )
+ .append( $brokenIcon );
+ const $srLabel = $( '<span></span>' )
+ .addClass( 'screen-reader-text' )
+ .attr( 'data-missing-file-label', 'true' )
+ .text( l10n.missingFileLabel || 'Attachment file missing' );
+
+ $hero.prepend( $brokenWrapper, $srLabel );
+ }
+
+ this.ensureHeroControls( $hero, isPrimary );
+ },
+
+ /**
+ * Ensure the primary-image badge and the Replace button are
+ * present in the hero slot. Idempotent.
+ *
+ * @param {jQuery} $hero
+ * @param {boolean} isPrimary
+ */
+ ensureHeroControls( $hero, isPrimary ) {
+ if ( ! $hero.find( SELECTORS.primaryBadge ).length ) {
+ const $badgeIcon = $( '<span></span>' ).addClass(
+ 'dashicons dashicons-star-filled'
+ );
+ const badgeLabel = document.createTextNode(
+ ' ' + ( l10n.primaryLabel || 'Primary' )
+ );
+ const $badge = $( '<span></span>' )
+ .addClass( 'wc-variation-gallery-field__badge' )
+ .attr( 'data-primary-badge', '' )
+ .attr( 'aria-hidden', 'true' )
+ .append( $badgeIcon )
+ .append( badgeLabel );
+
+ $hero.append( $badge );
+ }
+
+ $hero.find( SELECTORS.primaryBadge ).toggle( Boolean( isPrimary ) );
+
+ if ( ! $hero.find( SELECTORS.replaceTrigger ).length ) {
+ const $replace = $( '<button type="button"></button>' )
+ .addClass( 'button wc-variation-gallery-replace' )
+ .text( l10n.replaceLabel || 'Replace' );
+
+ $hero.append( $replace );
+ }
+ },
+
+ /**
+ * Render the empty-gallery state in the hero slot: a single CTA
+ * that opens the WP media frame.
+ *
+ * @param {jQuery} $field
+ */
+ setHeroEmpty( $field ) {
+ const $hero = $field.find( SELECTORS.fieldHero );
+
+ const $ctaIcon = $( '<span></span>' )
+ .addClass( 'dashicons dashicons-plus-alt2' )
+ .attr( 'aria-hidden', 'true' );
+ const ctaLabel = document.createTextNode(
+ ' ' + ( l10n.emptyCtaLabel || 'Add variation images' )
+ );
+ const $cta = $( '<button type="button"></button>' )
+ .addClass(
+ 'wc-variation-gallery-field__empty-cta wc-variation-gallery-manage'
+ )
+ .append( $ctaIcon )
+ .append( ctaLabel );
+
+ $hero.empty().attr( 'data-active-index', 0 ).append( $cta );
+ $field.addClass( CLASSES.isEmpty );
+ },
+
+ /**
+ * @param {jQuery} $field
+ * @return {number[]}
+ */
+ getFieldIds( $field ) {
+ return $field
+ .find( SELECTORS.thumb )
+ .map( function () {
+ return Number( $( this ).attr( 'data-attachment_id' ) );
+ } )
+ .get()
+ .filter( ( id ) => Number.isInteger( id ) && id > 0 );
+ },
+
+ /**
+ * Persist current DOM state back into the hidden form input and
+ * the legacy single-image slot, then refresh the count label.
+ *
+ * @param {jQuery} $field
+ */
+ syncField( $field ) {
+ const ids = this.getFieldIds( $field );
+ const primaryId = ids[ 0 ] || '';
+
+ $field
+ .find( SELECTORS.fieldImageIdsInput )
+ .val( ids.join( ',' ) )
+ .trigger( 'change' );
+
+ this.syncLegacyImageSlot( $field, primaryId );
+ this.updateFromDom( $field, ids.length );
+ },
+
+ /**
+ * Keep the existing single-image variation field (`upload_image_id`,
+ * its upload-image button, and the inline preview) in sync with the
+ * gallery's primary image. This lets the existing variation save
+ * path persist the featured image without changes.
+ *
+ * @param {jQuery} $field
+ * @param {number|string} primaryId Attachment ID, or empty string when no primary is set.
+ */
+ syncLegacyImageSlot( $field, primaryId ) {
+ const $row = $field.closest( SELECTORS.variationRow );
+
+ this.updateLegacyInput( $row, primaryId );
+ this.updateLegacyButton( $row, primaryId );
+ this.updateLegacyPreview( $row, primaryId );
+ },
+
+ /**
+ * Mirror the gallery's primary image into the hidden
+ * `upload_image_id[ loop ]` input that the variation save path
+ * already reads.
+ *
+ * @param {jQuery} $row
+ * @param {number|string} primaryId
+ */
+ updateLegacyInput( $row, primaryId ) {
+ const $input = $row.find( SELECTORS.legacyInput );
+ if ( ! $input.length ) {
+ return;
+ }
+ $input.val( primaryId ).trigger( 'change' );
+ },
+
+ /**
+ * Toggle the upload-image button's "remove" affordance based on
+ * whether a primary image is currently set.
+ *
+ * @param {jQuery} $row
+ * @param {number|string} primaryId
+ */
+ updateLegacyButton( $row, primaryId ) {
+ const $button = $row.find( SELECTORS.legacyButton );
+ if ( ! $button.length ) {
+ return;
+ }
+ $button.toggleClass( 'remove', Boolean( primaryId ) );
+ },
+
+ /**
+ * Update the inline thumbnail preview inside the upload-image
+ * button. Stashes the original placeholder src on the first call
+ * so it can be restored when the merchant clears the gallery.
+ *
+ * @param {jQuery} $row
+ * @param {number|string} primaryId
+ */
+ updateLegacyPreview( $row, primaryId ) {
+ const $preview = $row
+ .find( SELECTORS.legacyButton )
+ .find( 'img' )
+ .first();
+ if ( ! $preview.length ) {
+ return;
+ }
+
+ // Stash the placeholder src on first call so we can restore it later.
+ if ( ! $preview.attr( 'data-placeholder-src' ) ) {
+ $preview.attr(
+ 'data-placeholder-src',
+ $preview.attr( 'src' ) || ''
+ );
+ }
+
+ if ( ! primaryId ) {
+ $preview.attr(
+ 'src',
+ $preview.attr( 'data-placeholder-src' ) || ''
+ );
+ return;
+ }
+
+ const $field = $row.find( SELECTORS.field );
+ const url = this.resolveAttachmentUrl(
+ $field,
+ primaryId,
+ pickAttachmentThumbnailUrl
+ );
+ if ( url ) {
+ $preview.attr( 'src', url );
+ }
+ },
+
+ /**
+ * Refresh the count label, the empty-state class, and the hint
+ * visibility from the field's current image count. Pass an
+ * explicit count to skip the DOM lookup.
+ *
+ * @param {jQuery} $field
+ * @param {number|null} [precomputedCount=null]
+ */
+ updateFromDom( $field, precomputedCount = null ) {
+ const count =
+ precomputedCount === null
+ ? this.getFieldIds( $field ).length
+ : precomputedCount;
+ const template = l10n[ getCountTemplateKey( count ) ] || '%d';
+ const label = template.replace( '%d', count );
+
+ $field.toggleClass( CLASSES.isEmpty, count === 0 );
+ $field.find( SELECTORS.fieldCount ).text( label );
+ $field.find( SELECTORS.fieldHint ).prop( 'hidden', count === 0 );
+ },
+
+ restoreMediaPostId() {
+ wp.media.model.settings.post.id = this.wpMediaPostId;
+ },
+ };
+
+ variationGallery.init();
+} );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/add-to-cart-variation.js b/plugins/woocommerce/client/legacy/js/frontend/add-to-cart-variation.js
index b4334f7befa..b51d8d35450 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/add-to-cart-variation.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/add-to-cart-variation.js
@@ -869,22 +869,181 @@
}
};
+ /**
+ * Check whether the current gallery is using variation-specific markup.
+ */
+ $.fn.wc_variations_gallery_is_active = function () {
+ return 'yes' === $( this ).attr( 'data-product_gallery_active' );
+ };
+
+ /**
+ * Get the default product gallery HTML used for restoring classic templates.
+ *
+ * Prefer the snapshot embedded in this form. Fall back to the page-level
+ * defaults for older template overrides that don't include it.
+ */
+ $.fn.wc_get_default_product_gallery_html = function () {
+ var $form = $( this );
+ var $template = $form.find( '.wc-product-gallery-default-template' );
+
+ if ( $template.length ) {
+ return $.trim( $template.html() );
+ }
+
+ var defaults = window.wc_variation_gallery_defaults || {};
+ return defaults[ $form.data( 'product_id' ) ] || '';
+ };
+
+ /**
+ * Find the gallery root within a container.
+ */
+ $.fn.wc_find_gallery_root = function () {
+ return $( this )
+ .find( '.woocommerce-product-gallery' )
+ .addBack( '.woocommerce-product-gallery' )
+ .first();
+ };
+
+ /**
+ * Replace the current classic product gallery markup and reinitialize gallery behaviors.
+ */
+ $.fn.wc_variations_gallery_replace = function ( gallery_html, variation ) {
+ var $form = $( this ),
+ $product = $form.closest( '.product' ),
+ $current_gallery = $product.wc_find_gallery_root(),
+ $parsed_gallery = $( $.parseHTML( $.trim( gallery_html ) ) ),
+ $new_gallery = $parsed_gallery.wc_find_gallery_root(),
+ current_image_id =
+ variation && variation.image_id ? variation.image_id : '';
+
+ if ( ! $current_gallery.length || ! $new_gallery.length ) {
+ return false;
+ }
+
+ /**
+ * Notify subscribers that the current gallery DOM is about to be
+ * removed and replaced.
+ *
+ * @event wc-product-gallery-before-destroy
+ * @since 10.8.0
+ * @param {HTMLElement} galleryEl - The gallery root element being torn down.
+ * @param {Object} params - The `wc_single_product_params` object.
+ */
+ if ( typeof wc_single_product_params !== 'undefined' ) {
+ $current_gallery.trigger( 'wc-product-gallery-before-destroy', [
+ $current_gallery[ 0 ],
+ wc_single_product_params,
+ ] );
+ }
+
+ $current_gallery.replaceWith( $new_gallery );
+
+ if (
+ typeof $.fn.wc_product_gallery === 'function' &&
+ typeof wc_single_product_params !== 'undefined'
+ ) {
+ $new_gallery.trigger( 'wc-product-gallery-before-init', [
+ $new_gallery[ 0 ],
+ wc_single_product_params,
+ ] );
+
+ $new_gallery.wc_product_gallery( wc_single_product_params );
+
+ $new_gallery.trigger( 'wc-product-gallery-after-init', [
+ $new_gallery[ 0 ],
+ wc_single_product_params,
+ ] );
+ } else {
+ $new_gallery.css( 'opacity', 1 );
+ }
+
+ $form.attr( 'current-image', current_image_id );
+ $form.attr( 'data-product_gallery_active', 'yes' );
+
+ return true;
+ };
+
+ /**
+ * Restore the default classic product gallery markup after a variation gallery swap.
+ */
+ $.fn.wc_variations_gallery_reset = function () {
+ var $form = $( this ),
+ default_gallery_html = $form.wc_get_default_product_gallery_html(),
+ reset_gallery = false;
+
+ if ( default_gallery_html.length ) {
+ reset_gallery = $form.wc_variations_gallery_replace(
+ default_gallery_html,
+ false
+ );
+ }
+
+ if ( reset_gallery ) {
+ $form.removeAttr( 'data-product_gallery_active' );
+ $form.attr( 'current-image', '' );
+ }
+
+ return reset_gallery;
+ };
+
/**
* Sets product images for the chosen variation
*/
$.fn.wc_variations_image_update = function ( variation ) {
var $form = this,
$product = $form.closest( '.product' ),
- $product_gallery = $product.find( '.images' ),
- $gallery_nav = $product.find( '.flex-control-nav' ),
- $gallery_img = $gallery_nav.find( 'li:eq(0) img' ),
- $product_img_wrap = $product_gallery
- .find(
- '.woocommerce-product-gallery__image, .woocommerce-product-gallery__image--placeholder'
- )
- .eq( 0 ),
- $product_img = $product_img_wrap.find( '.wp-post-image' ),
- $product_link = $product_img_wrap.find( 'a' ).eq( 0 );
+ $product_gallery,
+ $gallery_nav,
+ $gallery_img,
+ $product_img_wrap,
+ $product_img,
+ $product_link;
+
+ if (
+ variation &&
+ variation.gallery_images_html &&
+ variation.gallery_images_html.length &&
+ $form.wc_variations_gallery_replace(
+ variation.gallery_images_html,
+ variation
+ )
+ ) {
+ window.setTimeout( function () {
+ $( window ).trigger( 'resize' );
+ }, 20 );
+
+ return;
+ }
+
+ if ( $form.wc_variations_gallery_is_active() ) {
+ var reset_succeeded = $form.wc_variations_gallery_reset();
+
+ if ( ! variation ) {
+ if ( reset_succeeded ) {
+ window.setTimeout( function () {
+ $( window ).trigger( 'resize' );
+ }, 20 );
+ }
+ return;
+ }
+
+ if ( ! reset_succeeded ) {
+ // Don't fall through to the single-image mutation path without
+ // a clean reset snapshot.
+ return;
+ }
+ }
+
+ $product_gallery = $product.find( '.images' );
+ $gallery_nav = $product.find( '.flex-control-nav' );
+ $gallery_img = $gallery_nav.find( 'li:eq(0) img' );
+ $product_img_wrap = $product_gallery
+ .find(
+ '.woocommerce-product-gallery__image, .woocommerce-product-gallery__image--placeholder'
+ )
+ .eq( 0 );
+ $product_img = $product_img_wrap.find( '.wp-post-image' );
+ $product_link = $product_img_wrap.find( 'a' ).eq( 0 );
if (
variation &&
@@ -1002,6 +1161,11 @@
$product_img = $product_img_wrap.find( '.wp-post-image' ),
$product_link = $product_img_wrap.find( 'a' ).eq( 0 );
+ if ( $form.wc_variations_gallery_is_active() ) {
+ $form.wc_variations_gallery_reset();
+ return;
+ }
+
$product_img.wc_reset_variation_attr( 'src' );
$product_img.wc_reset_variation_attr( 'width' );
$product_img.wc_reset_variation_attr( 'height' );
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
index d0ded7453fd..ea0bb0d9c4a 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
@@ -71,11 +71,23 @@ defined( 'ABSPATH' ) || exit;
<div class="woocommerce_variable_attributes wc-metabox-content" style="display: none;">
<div class="data">
<div class="form-flex-box">
- <p class="form-row upload_image">
+ <div class="form-row upload_image">
<a href="#" class="upload_image_button tips <?php echo $variation_object->get_image_id( 'edit' ) ? 'remove' : ''; ?>" data-tip="<?php echo $variation_object->get_image_id( 'edit' ) ? esc_attr__( 'Remove this image', 'woocommerce' ) : esc_attr__( 'Upload an image', 'woocommerce' ); ?>" rel="<?php echo esc_attr( $variation_id ); ?>">
<img src="<?php echo $variation_object->get_image_id( 'edit' ) ? esc_url( wp_get_attachment_thumb_url( $variation_object->get_image_id( 'edit' ) ) ) : esc_url( wc_placeholder_img_src() ); ?>" /><input type="hidden" name="upload_image_id[<?php echo esc_attr( $loop ); ?>]" class="upload_image_id" value="<?php echo esc_attr( $variation_object->get_image_id( 'edit' ) ); ?>" />
</a>
- </p>
+ <?php
+ /**
+ * Variation upload image action.
+ *
+ * @since 10.8.0
+ *
+ * @param int $loop Position in the loop.
+ * @param array $variation_data Variation data.
+ * @param WP_Post $variation Post data.
+ */
+ do_action( 'woocommerce_variation_after_upload_image', $loop, $variation_data, $variation );
+ ?>
+ </div>
<div class="form-row form-row-last">
<?php
if ( wc_product_sku_enabled() ) {
diff --git a/plugins/woocommerce/includes/class-wc-product-variable.php b/plugins/woocommerce/includes/class-wc-product-variable.php
index a2c971cfe9b..943c2db3546 100644
--- a/plugins/woocommerce/includes/class-wc-product-variable.php
+++ b/plugins/woocommerce/includes/class-wc-product-variable.php
@@ -10,6 +10,7 @@
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGalleryPackage;
defined( 'ABSPATH' ) || exit;
@@ -416,6 +417,45 @@ class WC_Product_Variable extends WC_Product {
if ( ! $variation instanceof WC_Product_Variation ) {
return false;
}
+
+ $variation_featured_id = (int) $variation->get_image_id();
+ $variation_featured_valid = $variation_featured_id && wp_attachment_is_image( $variation_featured_id );
+ $parent_featured_id = (int) $this->get_image_id();
+ $parent_featured_valid = $parent_featured_id && wp_attachment_is_image( $parent_featured_id );
+
+ $variation_gallery_image_ids = array();
+ $variation_gallery_html = '';
+
+ if ( VariationGalleryPackage::is_enabled() ) {
+ $variation_gallery_image_ids = array_values(
+ array_filter(
+ array_map( 'intval', $variation->get_gallery_image_ids() ),
+ 'wp_attachment_is_image'
+ )
+ );
+ }
+
+ // Prefer variation-owned images over the parent fallback.
+ if ( $variation_featured_valid ) {
+ $selected_image_id = $variation_featured_id;
+ } elseif ( ! empty( $variation_gallery_image_ids ) ) {
+ $selected_image_id = $variation_gallery_image_ids[0];
+ } elseif ( $parent_featured_valid ) {
+ $selected_image_id = $parent_featured_id;
+ } else {
+ $selected_image_id = 0;
+ }
+
+ if ( ! empty( $variation_gallery_image_ids ) ) {
+ $gallery_html_ids = $variation_gallery_image_ids;
+
+ if ( $selected_image_id && ! in_array( $selected_image_id, $gallery_html_ids, true ) ) {
+ array_unshift( $gallery_html_ids, $selected_image_id );
+ }
+
+ $variation_gallery_html = wc_get_product_gallery_html( $this, $gallery_html_ids );
+ }
+
// See if prices should be shown for each variation after selection.
$show_variation_price = apply_filters( 'woocommerce_show_variation_price', $variation->get_price() === '' || $this->get_variation_sale_price( 'min' ) !== $this->get_variation_sale_price( 'max' ) || $this->get_variation_regular_price( 'min' ) !== $this->get_variation_regular_price( 'max' ), $this, $variation );
@@ -429,8 +469,10 @@ class WC_Product_Variable extends WC_Product {
'dimensions_html' => wc_format_dimensions( $variation->get_dimensions( false ) ),
'display_price' => wc_get_price_to_display( $variation ),
'display_regular_price' => wc_get_price_to_display( $variation, array( 'price' => $variation->get_regular_price() ) ),
- 'image' => wc_get_product_attachment_props( $variation->get_image_id() ),
- 'image_id' => $variation->get_image_id(),
+ 'gallery_image_ids' => $variation_gallery_image_ids,
+ 'gallery_images_html' => $variation_gallery_html,
+ 'image' => wc_get_product_attachment_props( $selected_image_id ),
+ 'image_id' => $selected_image_id,
'is_downloadable' => $variation->is_downloadable(),
'is_in_stock' => $variation->is_in_stock(),
'is_purchasable' => $variation->is_purchasable(),
@@ -659,7 +701,8 @@ class WC_Product_Variable extends WC_Product {
$data_store = WC_Data_Store::load( 'product-' . $product->get_type() );
$data_store->sync_price( $product );
$data_store->sync_stock_status( $product );
- self::sync_attributes( $product ); // Legacy update of attributes.
+ self::sync_attributes( $product );
+ // Legacy update of attributes.
do_action( 'woocommerce_variable_product_sync_data', $product );
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 5999b66ea5c..c631625f155 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -394,6 +394,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\Admin\Settings\PaymentsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\VariationGallery\Telemetry::class )->register();
$container->get( Automattic\WooCommerce\Internal\Email\EmailStyleSync::class )->register();
$container->get( Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\Agentic\AgenticController::class )->register();
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 1582a04d0b9..3ed2b60d4d9 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -12,6 +12,8 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
+use Automattic\WooCommerce\Internal\VariationGallery\Telemetry as VariationGalleryTelemetry;
use Automattic\WooCommerce\Utilities\I18nUtil;
use Automattic\WooCommerce\Utilities\MetaDataUtil;
@@ -155,6 +157,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
'shipping_class' => $object->get_shipping_class(),
'shipping_class_id' => $object->get_shipping_class_id(),
'image' => $this->get_image( $object, $context ),
+ 'gallery_image_ids' => $object instanceof WC_Product ? array_map( 'intval', $object->get_gallery_image_ids() ) : array(),
'attributes' => $this->get_attributes( $object ),
'menu_order' => $object->get_menu_order(),
'meta_data' => $object->get_meta_data(),
@@ -192,6 +195,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
* @param WP_REST_Request $request Request object.
* @param bool $creating If is creating a new object.
* @return WP_Error|WC_Data
+ * @throws \Throwable When setting gallery_image_ids fails.
*/
protected function prepare_object_for_database( $request, $creating = false ) {
if ( isset( $request['id'] ) ) {
@@ -226,6 +230,37 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
}
}
+ if ( isset( $request['gallery_image_ids'] ) && $variation instanceof WC_Product_Variation ) {
+ // Enforce the schema: gallery is disjoint from the featured image.
+ $gallery_ids = array_values(
+ array_diff(
+ wp_parse_id_list( $request['gallery_image_ids'] ),
+ array( (int) $variation->get_image_id() )
+ )
+ );
+ try {
+ $variation->set_gallery_image_ids( $gallery_ids );
+ LegacyVariationGalleryCompatibility::mark_core_managed( $variation );
+ } catch ( \Throwable $e ) {
+ VariationGalleryTelemetry::record_event(
+ VariationGalleryTelemetry::EVENT_SAVE_FAILED,
+ array(
+ 'context' => 'rest_v3',
+ 'reason' => get_class( $e ),
+ )
+ );
+ throw $e;
+ }
+ VariationGalleryTelemetry::record_event(
+ VariationGalleryTelemetry::EVENT_SAVE_SUCCEEDED,
+ array(
+ 'context' => 'rest_v3',
+ 'image_count' => count( $gallery_ids ),
+ 'is_multi' => count( $gallery_ids ) > 1 ? 'yes' : 'no',
+ )
+ );
+ }
+
// Virtual variation.
if ( isset( $request['virtual'] ) ) {
$variation->set_virtual( $request['virtual'] );
@@ -822,6 +857,15 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
),
),
),
+ 'gallery_image_ids' => array(
+ 'description' => __( 'Variation gallery image IDs, excluding the featured image (which is set via "image"). Mirrors how galleries work on parent products.', 'woocommerce' ),
+ 'type' => 'array',
+ 'context' => array( 'view', 'edit' ),
+ 'items' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ ),
'attributes' => array(
'description' => __( 'List of attributes.', 'woocommerce' ),
'type' => 'array',
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index b6fd148c144..f088e6ba65b 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -1870,6 +1870,136 @@ function wc_get_gallery_image_html( $attachment_id, $main_image = false, $image_
return '<div data-thumb="' . esc_url( isset( $thumbnail_src[0] ) ? $thumbnail_src[0] : '' ) . '" data-thumb-alt="' . esc_attr( $alt_text ) . '" data-thumb-srcset="' . esc_attr( isset( $thumbnail_srcset ) ? $thumbnail_srcset : '' ) . '" data-thumb-sizes="' . esc_attr( isset( $thumbnail_sizes ) ? $thumbnail_sizes : '' ) . '" class="woocommerce-product-gallery__image"><a href="' . esc_url( isset( $full_src[0] ) ? $full_src[0] : '' ) . '">' . $image . '</a></div>';
}
+/**
+ * Get HTML for a complete product gallery.
+ *
+ * Delegates to the `single-product/product-image.php` template so themes that
+ * override that template are honored, and so any extensions hooked on
+ * `woocommerce_product_thumbnails` or `woocommerce_single_product_image_thumbnail_html`
+ * still fire on variation gallery swaps.
+ *
+ * @since 10.8.0
+ *
+ * @param WC_Product $product Product used for gallery context.
+ * @param array|null $image_ids Ordered image IDs to render. When null, the
+ * template renders the product's own featured
+ * image and gallery; otherwise the supplied IDs
+ * are presented to the template as if they were
+ * the product's own images (the first ID becomes
+ * the featured slot, the rest the gallery).
+ * @return string
+ */
+function wc_get_product_gallery_html( $product, $image_ids = null ) {
+ if ( ! $product instanceof WC_Product ) {
+ return '';
+ }
+
+ if ( null === $image_ids ) {
+ return wc_render_product_image_template_for( $product );
+ }
+
+ return wc_render_product_image_template_for_image_ids( $product, $image_ids );
+}
+
+/**
+ * Render the `single-product/product-image.php` template for a given product.
+ *
+ * Temporarily promotes $product into `$GLOBALS['product']` so the template,
+ * which reads from globals, sees the right object. Restores the previous
+ * global before returning.
+ *
+ * @since 10.8.0
+ * @internal
+ *
+ * @param WC_Product $product Product to render.
+ * @return string
+ */
+function wc_render_product_image_template_for( WC_Product $product ): string {
+ $had_previous_product = array_key_exists( 'product', $GLOBALS );
+ $previous_product = $had_previous_product ? $GLOBALS['product'] : null;
+ $GLOBALS['product'] = $product;
+
+ try {
+ return trim( wc_get_template_html( 'single-product/product-image.php' ) );
+ } finally {
+ if ( $had_previous_product ) {
+ $GLOBALS['product'] = $previous_product;
+ } else {
+ unset( $GLOBALS['product'] );
+ }
+ }
+}
+
+/**
+ * Render the product-image template using a caller-supplied image set instead
+ * of the product's stored featured + gallery.
+ *
+ * Used by the variation gallery swap path: the variation has its own ordered
+ * list of images, but we still want the parent product's gallery template to
+ * render them so theme overrides and `woocommerce_product_thumbnails` hooks
+ * apply uniformly. We achieve this by hooking the product's
+ * `get_image_id` / `get_gallery_image_ids` filters for the duration of the
+ * render; the template sees the substituted values and is none the wiser.
+ *
+ * @since 10.8.0
+ * @internal
+ *
+ * @param WC_Product $product Product being rendered.
+ * @param mixed $image_ids Image IDs to substitute. Will be normalized.
+ * @return string
+ */
+function wc_render_product_image_template_for_image_ids( WC_Product $product, $image_ids ): string {
+ $normalized = array_values( array_unique( array_map( 'intval', array_filter( (array) $image_ids ) ) ) );
+ $featured_id = $normalized[0] ?? 0;
+ $gallery_ids = array_slice( $normalized, 1 );
+
+ $remove_overrides = wc_apply_product_image_overrides( $product, $featured_id, $gallery_ids );
+
+ try {
+ return wc_render_product_image_template_for( $product );
+ } finally {
+ $remove_overrides();
+ }
+}
+
+/**
+ * Hook get_image_id / get_gallery_image_ids on a single product instance and
+ * return a callable that removes the hooks. Each filter only fires for the
+ * specified product ID — other product objects in the same render pass are
+ * unaffected.
+ *
+ * @since 10.8.0
+ * @internal
+ *
+ * @param WC_Product $product Product to scope the override to.
+ * @param int $featured_id Image ID to return from get_image_id.
+ * @param int[] $gallery_ids Image IDs to return from get_gallery_image_ids.
+ * @return callable Invocation of the returned callable removes both filters.
+ */
+function wc_apply_product_image_overrides( WC_Product $product, int $featured_id, array $gallery_ids ): callable {
+ $product_id = $product->get_id();
+
+ $featured_filter = static function ( $value, $instance ) use ( $product_id, $featured_id ) {
+ return ( $instance instanceof WC_Product && $instance->get_id() === $product_id )
+ ? $featured_id
+ : $value;
+ };
+
+ $gallery_filter = static function ( $value, $instance ) use ( $product_id, $gallery_ids ) {
+ return ( $instance instanceof WC_Product && $instance->get_id() === $product_id )
+ ? $gallery_ids
+ : $value;
+ };
+
+ add_filter( 'woocommerce_product_get_image_id', $featured_filter, 10, 2 );
+ add_filter( 'woocommerce_product_get_gallery_image_ids', $gallery_filter, 10, 2 );
+
+ return static function () use ( $featured_filter, $gallery_filter ) {
+ remove_filter( 'woocommerce_product_get_image_id', $featured_filter, 10 );
+ remove_filter( 'woocommerce_product_get_gallery_image_ids', $gallery_filter, 10 );
+ };
+}
+
if ( ! function_exists( 'woocommerce_get_alt_from_product_title_and_position' ) ) {
/**
@@ -2066,6 +2196,7 @@ if ( ! function_exists( 'woocommerce_variable_add_to_cart' ) ) {
*/
function woocommerce_variable_add_to_cart() {
global $product;
+ static $attached_gallery_defaults = array();
if ( ! ( $product instanceof WC_Product ) ) {
return;
@@ -2074,6 +2205,24 @@ if ( ! function_exists( 'woocommerce_variable_add_to_cart' ) ) {
// Enqueue variation scripts.
wp_enqueue_script( 'wc-add-to-cart-variation' );
+ // Attach a reset snapshot only when variation-gallery swaps are enabled.
+ if (
+ \Automattic\WooCommerce\Internal\VariationGallery\Package::is_enabled() &&
+ ! isset( $attached_gallery_defaults[ $product->get_id() ] )
+ ) {
+ wp_add_inline_script(
+ 'wc-add-to-cart-variation',
+ sprintf(
+ '(window.wc_variation_gallery_defaults = window.wc_variation_gallery_defaults || {})[%d] = %s;',
+ $product->get_id(),
+ wp_json_encode( wc_get_product_gallery_html( $product ) )
+ ),
+ 'before'
+ );
+
+ $attached_gallery_defaults[ $product->get_id() ] = true;
+ }
+
// Get Available variations?
$get_variations = count( $product->get_children() ) <= apply_filters( 'woocommerce_ajax_variation_threshold', 30, $product );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 73ae5c0095f..0432d84946c 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -6363,7 +6363,7 @@ parameters:
-
message: '#^Variable \$loop might not be defined\.$#'
identifier: variable.undefined
- count: 51
+ count: 52
path: includes/admin/meta-boxes/views/html-variation-admin.php
-
@@ -14118,12 +14118,6 @@ parameters:
count: 1
path: includes/class-wc-product-variable.php
- -
- message: '#^Parameter \#1 \$attachment_id of function wc_get_product_attachment_props expects int\|null, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/class-wc-product-variable.php
-
-
message: '#^Parameter \#1 \$dimensions of function wc_format_dimensions expects array, array\|string given\.$#'
identifier: argument.type
@@ -53925,12 +53919,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductGallery.php
- -
- message: '#^Call to an undefined method WC_Product\:\:get_available_variations\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Blocks/BlockTypes/ProductGallery.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductGallery\:\:render_dialog\(\) should return string but returns string\|false\.$#'
identifier: return.type
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
index 5e80e52a791..ac8b9e28302 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
@@ -44,7 +44,7 @@ class ProductGallery extends AbstractBlock {
$sizes = $image['sizes'];
$alt = $image['alt'];
$loading = 0 === $index ? 'fetchpriority="high"' : 'loading="lazy"';
- $images_html .= "<img data-image-id='{$id}' src='{$src}' srcset='{$srcset}' sizes='{$sizes}' loading='{$loading}' decoding='async' alt='{$alt}' />";
+ $images_html .= "<img data-image-id='{$id}' data-wp-watch='callbacks.toggleImageVisibility' src='{$src}' srcset='{$srcset}' sizes='{$sizes}' loading='{$loading}' decoding='async' alt='{$alt}' />";
}
ob_start();
?>
@@ -113,10 +113,12 @@ class ProductGallery extends AbstractBlock {
return '';
}
- $image_ids = ProductGalleryUtils::get_all_image_ids( $product );
- $number_of_images = count( $image_ids );
+ $image_ids = ProductGalleryUtils::get_all_image_ids( $product );
+ $default_image_ids = array_map( 'intval', ProductGalleryUtils::get_product_gallery_image_ids( $product ) );
+
+ $number_of_images = count( $default_image_ids );
$classname = StyleAttributesUtils::get_classes_by_attributes( $attributes, array( 'extra_classes' ) );
- $initial_image_id = $number_of_images > 0 ? $image_ids[0] : -1;
+ $initial_image_id = $number_of_images > 0 ? $default_image_ids[0] : -1;
$classname_single_image = $number_of_images < 2 ? 'is-single-product-gallery-image' : '';
$product_id = strval( $product->get_id() );
$fullsize_image_data = ProductGalleryUtils::get_image_src_data( $image_ids, 'full', $product->get_title() );
@@ -130,7 +132,7 @@ class ProductGallery extends AbstractBlock {
'data-wp-context',
wp_json_encode(
array(
- 'imageData' => $image_ids,
+ 'imageData' => $default_image_ids,
'isDialogOpen' => false,
'isDragging' => false,
'touchStartX' => 0,
@@ -146,7 +148,7 @@ class ProductGallery extends AbstractBlock {
// Next/Previous Buttons block context.
'hideNextPreviousButtons' => $number_of_images <= 1,
'isDisabledPrevious' => true,
- 'isDisabledNext' => false,
+ 'isDisabledNext' => $number_of_images <= 1,
'ariaLabelPrevious' => __( 'Previous image', 'woocommerce' ),
'ariaLabelNext' => __( 'Next image', 'woocommerce' ),
),
@@ -155,15 +157,22 @@ class ProductGallery extends AbstractBlock {
);
if ( $product->is_type( ProductType::VARIABLE ) ) {
- $has_variation_images = false;
- foreach ( $product->get_available_variations( 'objects' ) as $variation ) {
- if ( (int) $variation->get_image_id() ) {
- $has_variation_images = true;
- break;
- }
- }
+ $formatted_variations_data = ProductGalleryUtils::get_product_variation_gallery_data( $product );
+
+ if ( ! empty( $formatted_variations_data ) ) {
+ wp_interactivity_config(
+ 'woocommerce',
+ array(
+ 'products' => array(
+ $product->get_id() => array(
+ 'image_id' => (int) $product->get_image_id(),
+ 'image_ids' => $default_image_ids,
+ 'variations' => $formatted_variations_data,
+ ),
+ ),
+ )
+ );
- if ( $has_variation_images ) {
// Support legacy Add to Cart with Options block.
$p->set_attribute( 'data-wp-init--watch-changes-on-add-to-cart-form', 'callbacks.watchForChangesOnAddToCartForm' );
// Support blockified Add to Cart + Options block.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
index 2b750105ae0..e786048096d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
@@ -158,6 +158,7 @@ class ProductGalleryLargeImage extends AbstractBlock {
$p->set_attribute( 'tabindex', '-1' );
$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' );
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
index 4096c37d149..d7fa9a8913d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
@@ -76,6 +76,7 @@ class ProductGalleryThumbnails extends AbstractBlock {
class="wc-block-product-gallery-thumbnails wc-block-product-gallery-thumbnails--active-<?php echo esc_attr( $active_thumbnail_style ); ?> <?php echo esc_attr( $classes_and_styles['classes'] ); ?>"
style="<?php echo '--wc-block-product-gallery-thumbnails-size:' . absint( $thumbnail_size ) . ';' . esc_attr( $classes_and_styles['styles'] ); ?>"
data-wp-interactive="woocommerce/product-gallery"
+ data-wp-bind--hidden="context.hideNextPreviousButtons"
data-wp-class--wc-block-product-gallery-thumbnails--overflow-top="context.thumbnailsOverflow.top"
data-wp-class--wc-block-product-gallery-thumbnails--overflow-bottom="context.thumbnailsOverflow.bottom"
data-wp-class--wc-block-product-gallery-thumbnails--overflow-left="context.thumbnailsOverflow.left"
@@ -97,7 +98,7 @@ class ProductGalleryThumbnails extends AbstractBlock {
alt="<?php echo esc_attr( $image['alt'] ); ?>"
data-wp-on--click="actions.selectCurrentImage"
data-wp-on--keydown="actions.onThumbnailsArrowsKeyDown"
- data-wp-watch="callbacks.toggleActiveThumbnailAttributes"
+ data-wp-watch="callbacks.syncThumbnailState"
decoding="async"
tabindex="<?php echo 0 === $index ? '0' : '-1'; ?>"
draggable="false"
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
index f873ad787b6..2fe6dbe246f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
@@ -172,7 +172,13 @@ class ProductImage extends AbstractBlock {
$target_image_id = $provided_image_id_is_valid ? $image_id : $featured_image_id;
if ( ! $target_image_id ) {
- return wc_placeholder_img( $image_size, array( 'style' => $image_style ) );
+ return wc_placeholder_img(
+ $image_size,
+ array(
+ 'style' => $image_style,
+ 'data-image-id' => 0,
+ )
+ );
}
$alt_text = get_post_meta( $target_image_id, '_wp_attachment_image_alt', true );
diff --git a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
index 4bc75c0dfce..abe9b2300c6 100644
--- a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
+++ b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
@@ -1,6 +1,8 @@
<?php
namespace Automattic\WooCommerce\Blocks\Utils;
+use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGalleryPackage;
+
/**
* Utility methods used for the Product Gallery block.
* {@internal This class and its methods are not intended for public use.}
@@ -117,18 +119,10 @@ class ProductGalleryUtils {
try {
if ( $product->is_type( 'variable' ) ) {
- $variations = $product->get_children();
- if ( ! empty( $variations ) ) {
- _prime_post_caches( $variations );
- }
- foreach ( $variations as $variation_id ) {
- $variation = wc_get_product( $variation_id );
- if ( $variation ) {
- $variation_image_id = $variation->get_image_id();
- if ( ! empty( $variation_image_id ) && ! in_array( strval( $variation_image_id ), $variation_image_ids, true ) ) {
- $variation_image_ids[] = strval( $variation_image_id );
- }
- }
+ $variation_gallery_data = self::get_product_variation_gallery_data( $product );
+
+ foreach ( $variation_gallery_data as $variation_data ) {
+ $variation_image_ids = array_merge( $variation_image_ids, $variation_data['image_ids'] );
}
}
} catch ( \Exception $e ) {
@@ -136,7 +130,146 @@ class ProductGalleryUtils {
error_log( 'Error getting product variation image IDs: ' . $e->getMessage() );
}
- return $variation_image_ids;
+ $unique_int_ids = array_unique( array_map( 'intval', $variation_image_ids ) );
+
+ return array_values( array_map( 'strval', $unique_int_ids ) );
+ }
+
+ /**
+ * Get variation gallery data keyed by variation ID.
+ *
+ * @param \WC_Product $product The product object to retrieve variation gallery data for.
+ * @return array<int, array<string, mixed>> Variation gallery data.
+ */
+ public static function get_product_variation_gallery_data( $product ) {
+ $variation_gallery_data = array();
+
+ if ( ! $product instanceof \WC_Product ) {
+ wc_doing_it_wrong( __FUNCTION__, __( 'Invalid product object.', 'woocommerce' ), '10.8.0' );
+ return $variation_gallery_data;
+ }
+
+ if ( ! $product->is_type( 'variable' ) ) {
+ return $variation_gallery_data;
+ }
+
+ $variations = $product->get_children();
+ if ( ! empty( $variations ) ) {
+ // Bulk-load posts + postmeta into WP's object cache.
+ _prime_post_caches( $variations );
+ }
+
+ $parent_image_ids = array_values(
+ array_filter(
+ array_map( 'intval', self::get_product_gallery_image_ids( $product ) ),
+ static function ( $id ) {
+ return $id > 0 && wp_attachment_is_image( $id );
+ }
+ )
+ );
+
+ foreach ( $variations as $variation_id ) {
+ $variation_id = (int) $variation_id;
+ $entry = self::build_variation_gallery_entry( $variation_id, $parent_image_ids );
+
+ if ( null !== $entry ) {
+ $variation_gallery_data[ $variation_id ] = $entry;
+ }
+ }
+
+ return $variation_gallery_data;
+ }
+
+ /**
+ * Build the gallery payload for a single variation, or null when the
+ * variation has no images or isn't a real variation.
+ *
+ * @param int $variation_id Variation post ID.
+ * @param int[] $parent_image_ids Parent product's full gallery (featured + extras),
+ * used as fallback when the variation has no images.
+ * @return array<string, mixed>|null
+ */
+ private static function build_variation_gallery_entry( int $variation_id, array $parent_image_ids ): ?array {
+ $variation = wc_get_product( $variation_id );
+
+ if ( ! $variation instanceof \WC_Product_Variation ) {
+ return null;
+ }
+
+ $variation_image_id = (int) $variation->get_image_id();
+ $variation_image_valid = $variation_image_id && wp_attachment_is_image( $variation_image_id );
+ $parent_fallback = ! empty( $parent_image_ids )
+ ? array(
+ 'image_id' => $parent_image_ids[0],
+ 'image_ids' => $parent_image_ids,
+ )
+ : array(
+ 'image_id' => 0,
+ 'image_ids' => array( 0 ),
+ );
+
+ if ( ! VariationGalleryPackage::is_enabled() ) {
+ if ( $variation_image_valid ) {
+ return array(
+ 'image_id' => $variation_image_id,
+ 'image_ids' => array( $variation_image_id ),
+ );
+ }
+ return $parent_fallback;
+ }
+
+ $image_ids = self::get_variation_gallery_image_ids( $variation );
+
+ if ( empty( $image_ids ) ) {
+ return $parent_fallback;
+ }
+
+ // Prefer variation-owned images over the parent fallback.
+ if ( $variation_image_valid ) {
+ $selected_image_id = $variation_image_id;
+ } else {
+ $selected_image_id = $image_ids[0];
+ }
+
+ if ( ! in_array( $selected_image_id, $image_ids, true ) ) {
+ array_unshift( $image_ids, $selected_image_id );
+ }
+
+ return array(
+ 'image_id' => $selected_image_id,
+ 'image_ids' => $image_ids,
+ );
+ }
+
+ /**
+ * Get all image IDs relevant to a variation gallery.
+ *
+ * @param \WC_Product_Variation $variation The variation object.
+ * @return array<int> Variation image IDs.
+ */
+ public static function get_variation_gallery_image_ids( \WC_Product_Variation $variation ) {
+ $image_ids = array();
+ $variation_image_id = (int) $variation->get_image_id();
+ $gallery_image_ids = array_map( 'intval', $variation->get_gallery_image_ids() );
+
+ if ( $variation_image_id ) {
+ $image_ids[] = $variation_image_id;
+ }
+
+ if ( ! empty( $gallery_image_ids ) ) {
+ $image_ids = array_merge( $image_ids, $gallery_image_ids );
+ }
+
+ // Filter out missing/invalid attachments to avoid rendering phantom
+ // empty `<li>` wrappers that the visibility watch can't manage.
+ $image_ids = array_filter(
+ $image_ids,
+ function ( $id ) {
+ return $id > 0 && wp_attachment_is_image( $id );
+ }
+ );
+
+ return array_values( array_unique( $image_ids ) );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index caa86505e46..1ddbc738ab6 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -512,6 +512,18 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'enabled_by_default' => false,
),
+ \Automattic\WooCommerce\Internal\VariationGallery\Package::FEATURE_ID => array(
+ 'name' => __( 'Variation gallery', 'woocommerce' ),
+ 'description' => __(
+ 'Add multiple images per product variation. Once enabled, the Additional Variation Images extension will be deactivated and its data migrated.',
+ 'woocommerce'
+ ),
+ 'option_key' => \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME,
+ 'is_experimental' => true,
+ 'enabled_by_default' => false,
+ 'skip_compatibility_checks' => true,
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
'point_of_sale' => array(
'name' => __( 'Point of Sale', 'woocommerce' ),
'description' => __(
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/ClassicVariationGalleryAdmin.php b/plugins/woocommerce/src/Internal/VariationGallery/ClassicVariationGalleryAdmin.php
new file mode 100644
index 00000000000..68f0ac81361
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/ClassicVariationGalleryAdmin.php
@@ -0,0 +1,372 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Product_Variation;
+use WP_Post;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Adds a classic-editor authoring UI for variation galleries.
+ *
+ * The editor unifies the variation's featured image and gallery into a single
+ * ordered list. The legacy single-image slot is hidden visually, and kept
+ * in sync with the first gallery image via JS.
+ *
+ * This preserves the existing variation save path while giving merchants
+ * one control to manage.
+ */
+class ClassicVariationGalleryAdmin implements RegisterHooksInterface {
+
+ private const SCRIPT_HANDLE = 'wc-admin-variation-gallery';
+
+ private const STYLE_HANDLE = 'wc-admin-variation-gallery-styles';
+
+ /**
+ * Register hooks.
+ *
+ * @return void
+ */
+ public function register() {
+ add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ), 20 );
+ add_action( 'woocommerce_variation_after_upload_image', array( $this, 'render_variation_gallery_field' ), 10, 3 );
+ add_action( 'woocommerce_admin_process_variation_object', array( $this, 'persist_variation_gallery_field' ), 10, 2 );
+ }
+
+ /**
+ * Enqueue admin assets for the classic variation gallery editor.
+ *
+ * @return void
+ */
+ public function enqueue_assets(): void {
+ if ( ! $this->is_product_edit_screen() ) {
+ return;
+ }
+
+ $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
+
+ wp_enqueue_script(
+ self::SCRIPT_HANDLE,
+ \WC()->plugin_url() . '/assets/js/admin/variation-gallery' . $suffix . '.js',
+ array( 'wc-admin-variation-meta-boxes', 'wp-a11y' ),
+ Constants::get_constant( 'WC_VERSION' ),
+ true
+ );
+
+ wp_localize_script(
+ self::SCRIPT_HANDLE,
+ 'wcVariationGalleryL10n',
+ array(
+ 'manageTitle' => __( 'Manage variation gallery', 'woocommerce' ),
+ 'manageButton' => __( 'Update gallery', 'woocommerce' ),
+ 'replaceTitle' => __( 'Replace image', 'woocommerce' ),
+ 'replaceButton' => __( 'Use this image', 'woocommerce' ),
+ 'replaceLabel' => __( 'Replace', 'woocommerce' ),
+ 'addTitle' => __( 'Add images to variation gallery', 'woocommerce' ),
+ 'addButton' => __( 'Add to gallery', 'woocommerce' ),
+ 'emptyCtaLabel' => __( 'Add variation images', 'woocommerce' ),
+ 'announceUpdated' => __( 'Variation gallery updated.', 'woocommerce' ),
+ 'announceReplaced' => __( 'Image replaced.', 'woocommerce' ),
+ 'announceRemoved' => __( 'Image removed from variation gallery.', 'woocommerce' ),
+ 'announceReorder' => __( 'Variation gallery order updated.', 'woocommerce' ),
+ 'announcePrimary' => __( 'New primary image set.', 'woocommerce' ),
+ 'removeLabel' => __( 'Remove image', 'woocommerce' ),
+ 'countZero' => __( 'No images yet', 'woocommerce' ),
+ /* translators: %d: number of variation gallery images */
+ 'countSingular' => __( '%d image', 'woocommerce' ),
+ /* translators: %d: number of variation gallery images */
+ 'countPlural' => __( '%d images', 'woocommerce' ),
+ 'primaryLabel' => __( 'Primary', 'woocommerce' ),
+ /* translators: %d: gallery image position */
+ 'thumbLabel' => __( 'Show gallery image %d', 'woocommerce' ),
+ 'missingFileLabel' => __( 'Attachment file missing', 'woocommerce' ),
+ )
+ );
+
+ wp_enqueue_style(
+ self::STYLE_HANDLE,
+ \WC()->plugin_url() . '/assets/css/variation-gallery-admin.css',
+ array(),
+ Constants::get_constant( 'WC_VERSION' )
+ );
+ }
+
+ /**
+ * Render the variation gallery field.
+ *
+ * @param int $loop Variation row index.
+ * @param array $variation_data Variation data.
+ * @param WP_Post $variation Variation post object.
+ * @return void
+ */
+ public function render_variation_gallery_field( int $loop, array $variation_data, WP_Post $variation ): void {
+ $variation_object = wc_get_product( $variation->ID );
+
+ if ( ! $variation_object instanceof WC_Product_Variation ) {
+ return;
+ }
+
+ $image_ids = $this->get_display_image_ids( $variation_object );
+ $count = count( $image_ids );
+ $field_id = 'variable_gallery_image_ids_' . $loop;
+ $hero_id = $count > 0 ? $image_ids[0] : 0;
+ ?>
+ <div
+ class="wc-variation-gallery-field<?php echo 0 === $count ? ' is-empty' : ''; ?>"
+ data-variation-id="<?php echo esc_attr( (string) $variation->ID ); ?>"
+ >
+ <div class="wc-variation-gallery-field__header">
+ <div class="wc-variation-gallery-field__title-block">
+ <strong class="wc-variation-gallery-field__title">
+ <?php esc_html_e( 'Variation gallery', 'woocommerce' ); ?>
+ </strong>
+ <span class="wc-variation-gallery-field__count" aria-live="polite">
+ <?php echo esc_html( $this->get_count_text( $count ) ); ?>
+ </span>
+ </div>
+ <button
+ type="button"
+ class="button-link wc-variation-gallery-manage"
+ aria-label="<?php esc_attr_e( 'Manage variation gallery images', 'woocommerce' ); ?>"
+ >
+ <?php esc_html_e( 'Manage', 'woocommerce' ); ?>
+ </button>
+ </div>
+
+ <div class="wc-variation-gallery-field__hero" data-active-index="0">
+ <?php if ( $hero_id > 0 ) : ?>
+ <?php $this->render_hero_image( $hero_id ); ?>
+ <span class="wc-variation-gallery-field__badge" data-primary-badge aria-hidden="true">
+ <span class="dashicons dashicons-star-filled"></span>
+ <?php esc_html_e( 'Primary', 'woocommerce' ); ?>
+ </span>
+ <button type="button" class="button wc-variation-gallery-replace">
+ <?php esc_html_e( 'Replace', 'woocommerce' ); ?>
+ </button>
+ <?php else : ?>
+ <button type="button" class="wc-variation-gallery-field__empty-cta wc-variation-gallery-manage">
+ <span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span>
+ <?php esc_html_e( 'Add variation images', 'woocommerce' ); ?>
+ </button>
+ <?php endif; ?>
+ </div>
+
+ <ul class="wc-variation-gallery-field__thumbs">
+ <?php foreach ( $image_ids as $index => $image_id ) : ?>
+ <?php $this->render_thumbnail( $image_id, 0 === $index ); ?>
+ <?php endforeach; ?>
+ </ul>
+
+ <p class="wc-variation-gallery-field__hint"<?php echo 0 === $count ? ' hidden' : ''; ?>>
+ <?php esc_html_e( 'First image is used as the primary. Drag to reorder.', 'woocommerce' ); ?>
+ </p>
+
+ <input
+ type="hidden"
+ id="<?php echo esc_attr( $field_id ); ?>"
+ name="variable_gallery_image_ids[<?php echo esc_attr( (string) $loop ); ?>]"
+ class="wc-variation-gallery-image-ids"
+ value="<?php echo esc_attr( implode( ',', $image_ids ) ); ?>"
+ />
+ </div>
+ <?php
+ }
+
+ /**
+ * Persist the variation gallery field.
+ *
+ * The merchant-facing UI presents featured + gallery as a single ordered
+ * list.
+ *
+ * @param WC_Product_Variation $variation Variation being saved.
+ * @param int $index Variation row index.
+ * @return void
+ * @throws \Throwable When setting the variation image or gallery fails.
+ */
+ public function persist_variation_gallery_field( WC_Product_Variation $variation, int $index ): void {
+ // We verify the variation save nonce before firing `woocommerce_admin_process_variation_object`.
+ if ( ! isset( $_POST['variable_gallery_image_ids'][ $index ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ return;
+ }
+
+ $unified_ids = wp_parse_id_list( wp_unslash( $_POST['variable_gallery_image_ids'][ $index ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $featured_id = (int) ( $unified_ids[0] ?? 0 );
+ $gallery_ids = array_values( array_slice( $unified_ids, 1 ) );
+
+ try {
+ $variation->set_image_id( $featured_id );
+ $variation->set_gallery_image_ids( $gallery_ids );
+ LegacyVariationGalleryCompatibility::mark_core_managed( $variation );
+ } catch ( \Throwable $e ) {
+ Telemetry::record_event(
+ Telemetry::EVENT_SAVE_FAILED,
+ array(
+ 'context' => 'classic_admin',
+ 'reason' => get_class( $e ),
+ )
+ );
+ throw $e;
+ }
+
+ Telemetry::record_event(
+ Telemetry::EVENT_SAVE_SUCCEEDED,
+ array(
+ 'context' => 'classic_admin',
+ 'image_count' => count( $unified_ids ),
+ 'is_multi' => count( $unified_ids ) > 1 ? 'yes' : 'no',
+ )
+ );
+ }
+
+ /**
+ * Display-time image list (render-only).
+ *
+ * Prepends the variation's featured image to the gallery when it's not
+ * already present, so the meta-box UI shows a single ordered set instead
+ * of two separate fields. This synthesis is **not persisted** — storage
+ * only changes when the merchant saves the variation, at which point
+ * `gallery[0]` becomes the canonical primary image.
+ *
+ * @param WC_Product_Variation $variation Variation object.
+ * @return array<int>
+ */
+ private function get_display_image_ids( WC_Product_Variation $variation ): array {
+ $image_ids = array_values( array_map( 'intval', $variation->get_gallery_image_ids() ) );
+ $featured_id = (int) $variation->get_image_id();
+
+ if ( $featured_id > 0 && ! in_array( $featured_id, $image_ids, true ) ) {
+ array_unshift( $image_ids, $featured_id );
+ }
+
+ if ( ! empty( $image_ids ) ) {
+ _prime_post_caches( $image_ids );
+ }
+
+ return $image_ids;
+ }
+
+ /**
+ * Render the hero image.
+ *
+ * @param int $image_id Attachment ID.
+ * @return void
+ */
+ private function render_hero_image( int $image_id ): void {
+ $html = wp_get_attachment_image(
+ $image_id,
+ 'woocommerce_single',
+ false,
+ array(
+ 'class' => 'wc-variation-gallery-field__hero-img',
+ 'data-id' => (string) $image_id,
+ 'decoding' => 'async',
+ 'loading' => 'lazy',
+ )
+ );
+
+ if ( '' === $html ) {
+ ?>
+ <span class="wc-variation-gallery-field__hero-broken" aria-hidden="true">
+ <span class="dashicons dashicons-format-image"></span>
+ </span>
+ <span class="screen-reader-text">
+ <?php esc_html_e( 'Attachment file missing', 'woocommerce' ); ?>
+ </span>
+ <?php
+ return;
+ }
+
+ echo wp_kses_post( $html );
+ }
+
+ /**
+ * Render a single thumbnail list item.
+ *
+ * @param int $image_id Attachment ID.
+ * @param bool $is_active Whether this thumbnail is the active/primary one.
+ * @return void
+ */
+ private function render_thumbnail( int $image_id, bool $is_active ): void {
+ $thumbnail = wp_get_attachment_image( $image_id, 'thumbnail' );
+ $is_broken = '' === $thumbnail;
+ $classes = array( 'wc-variation-gallery-thumb' );
+
+ if ( $is_active ) {
+ $classes[] = 'is-active';
+ }
+
+ if ( $is_broken ) {
+ $classes[] = 'is-broken';
+ }
+
+ /* translators: %d attachment ID */
+ $thumb_label = sprintf( __( 'Show gallery image %d', 'woocommerce' ), $image_id );
+ ?>
+ <li
+ class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
+ data-attachment_id="<?php echo esc_attr( (string) $image_id ); ?>"
+ >
+ <button
+ type="button"
+ class="wc-variation-gallery-thumb__button"
+ aria-label="<?php echo esc_attr( $thumb_label ); ?>"
+ >
+ <?php if ( $is_broken ) : ?>
+ <span class="wc-variation-gallery-thumb__broken" aria-hidden="true">
+ <span class="dashicons dashicons-format-image"></span>
+ </span>
+ <span class="screen-reader-text">
+ <?php esc_html_e( 'Attachment file missing', 'woocommerce' ); ?>
+ </span>
+ <?php else : ?>
+ <?php echo wp_kses_post( $thumbnail ); ?>
+ <?php endif; ?>
+ </button>
+ <button
+ type="button"
+ class="wc-variation-gallery-thumb__remove"
+ aria-label="<?php esc_attr_e( 'Remove image', 'woocommerce' ); ?>"
+ >
+ <span class="dashicons dashicons-no-alt" aria-hidden="true"></span>
+ </button>
+ </li>
+ <?php
+ }
+
+ /**
+ * Get the image count label shown beside the field title.
+ *
+ * @param int $count Number of images.
+ * @return string
+ */
+ private function get_count_text( int $count ): string {
+ if ( 0 === $count ) {
+ return __( 'No images yet', 'woocommerce' );
+ }
+
+ return sprintf(
+ /* translators: %d number of variation gallery images */
+ _n( '%d image', '%d images', $count, 'woocommerce' ),
+ $count
+ );
+ }
+
+ /**
+ * Determine if the current screen is the classic product editor.
+ *
+ * @return bool
+ */
+ private function is_product_edit_screen(): bool {
+ if ( ! function_exists( 'get_current_screen' ) ) {
+ return false;
+ }
+
+ $screen = get_current_screen();
+
+ return $screen && 'product' === $screen->id;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/LegacyVariationGalleryCompatibility.php b/plugins/woocommerce/src/Internal/VariationGallery/LegacyVariationGalleryCompatibility.php
new file mode 100644
index 00000000000..77759199c04
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/LegacyVariationGalleryCompatibility.php
@@ -0,0 +1,112 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Product_Variation;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Provides read compatibility for variation galleries stored by the
+ * Additional Variation Images extension.
+ */
+class LegacyVariationGalleryCompatibility implements RegisterHooksInterface {
+
+ /**
+ * Legacy meta key used by the retired extension.
+ */
+ private const LEGACY_META_KEY = '_wc_additional_variation_images';
+
+ /**
+ * Marks a variation as explicitly managed by core, so legacy fallback stops applying.
+ */
+ private const LEGACY_FALLBACK_DISABLED_META_KEY = '_wc_variation_gallery_legacy_fallback_disabled';
+
+ /**
+ * Get the internal meta key used to mark legacy fallback as disabled.
+ *
+ * @return string
+ */
+ public static function get_core_managed_meta_key(): string {
+ return self::LEGACY_FALLBACK_DISABLED_META_KEY;
+ }
+
+ /**
+ * Mark a variation as managed by core so legacy fallback stops applying.
+ *
+ * @param WC_Product_Variation $variation Variation managed by core.
+ * @return void
+ */
+ public static function mark_core_managed( WC_Product_Variation $variation ): void {
+ if ( ! metadata_exists( 'post', $variation->get_id(), self::LEGACY_META_KEY ) ) {
+ return;
+ }
+
+ $variation->update_meta_data( self::LEGACY_FALLBACK_DISABLED_META_KEY, 'yes' );
+ }
+
+ /**
+ * Mark a variation ID as managed by core so legacy fallback stops applying.
+ *
+ * @param int $variation_id Variation ID managed by core.
+ * @return void
+ */
+ public static function mark_variation_id_core_managed( int $variation_id ): void {
+ if ( ! metadata_exists( 'post', $variation_id, self::LEGACY_META_KEY ) ) {
+ return;
+ }
+
+ update_post_meta( $variation_id, self::LEGACY_FALLBACK_DISABLED_META_KEY, 'yes' );
+ }
+
+ /**
+ * Determine whether a variation ID is already managed by core.
+ *
+ * @param int $variation_id Variation ID.
+ * @return bool
+ */
+ public static function is_variation_id_core_managed( int $variation_id ): bool {
+ return metadata_exists( 'post', $variation_id, self::LEGACY_FALLBACK_DISABLED_META_KEY );
+ }
+
+ /**
+ * Register compatibility hooks.
+ *
+ * @return void
+ */
+ public function register() {
+ add_filter( 'woocommerce_product_variation_get_gallery_image_ids', array( $this, 'maybe_read_legacy_gallery_image_ids' ), 10, 2 );
+ }
+
+ /**
+ * Use legacy variation gallery meta when the core gallery is empty and the
+ * variation has not been marked as core-managed.
+ *
+ * @param array<mixed> $gallery_image_ids Gallery image IDs already resolved by core.
+ * @param WC_Product_Variation $variation Variation instance.
+ * @return array<int>
+ */
+ public function maybe_read_legacy_gallery_image_ids( $gallery_image_ids, WC_Product_Variation $variation ): array {
+ // Core has variation images, just normalize.
+ if ( ! empty( $gallery_image_ids ) ) {
+ return array_values( wp_parse_id_list( $gallery_image_ids ) );
+ }
+
+ // Sentinel set: respect the explicit "no images" choice; legacy meta remains for BC.
+ if ( self::is_variation_id_core_managed( $variation->get_id() ) ) {
+ return array();
+ }
+
+ $legacy_gallery_image_ids = get_post_meta( $variation->get_id(), self::LEGACY_META_KEY, true );
+
+ // Nothing to fall back to.
+ if ( empty( $legacy_gallery_image_ids ) ) {
+ return array();
+ }
+
+ // Pre-migration variation: fall back to the legacy extension's meta.
+ return array_values( wp_parse_id_list( $legacy_gallery_image_ids ) );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/Migration.php b/plugins/woocommerce/src/Internal/VariationGallery/Migration.php
new file mode 100644
index 00000000000..93b32c672ba
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/Migration.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * Batched migration of legacy variation gallery meta into core's native prop.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Migrate legacy variation gallery meta into WooCommerce's native variation gallery prop.
+ */
+class Migration {
+
+ /**
+ * Number of variations processed per batch.
+ */
+ private const BATCH_SIZE = 250;
+
+ /**
+ * Option name recording when the migration finished.
+ */
+ public const COMPLETED_OPTION = 'wc_variation_gallery_migration_completed_at';
+
+ /**
+ * Run one batch of the migration.
+ *
+ * @return bool Whether there are pending migration records.
+ */
+ public static function run(): bool {
+ global $wpdb;
+
+ if ( get_option( self::COMPLETED_OPTION ) ) {
+ return false;
+ }
+
+ $legacy_meta_key = '_wc_additional_variation_images';
+ $core_gallery_meta = '_product_image_gallery';
+ $fallback_disabled = LegacyVariationGalleryCompatibility::get_core_managed_meta_key();
+ $select_variation_ids = static function ( int $limit ) use ( $wpdb, $legacy_meta_key, $fallback_disabled ): array {
+ $query = $wpdb->prepare(
+ "SELECT legacy.post_id
+ FROM {$wpdb->postmeta} AS legacy
+ INNER JOIN {$wpdb->posts} AS posts
+ ON posts.ID = legacy.post_id
+ AND posts.post_type = 'product_variation'
+ LEFT JOIN {$wpdb->postmeta} AS disabled
+ ON disabled.post_id = legacy.post_id
+ AND disabled.meta_key = %s
+ WHERE legacy.meta_key = %s
+ AND legacy.meta_value <> ''
+ AND disabled.post_id IS NULL
+ GROUP BY legacy.post_id
+ ORDER BY legacy.post_id ASC
+ LIMIT %d",
+ $fallback_disabled,
+ $legacy_meta_key,
+ $limit
+ );
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared immediately above.
+ return array_map( 'intval', $wpdb->get_col( $query ) );
+ };
+
+ $variation_ids = $select_variation_ids( self::BATCH_SIZE );
+
+ foreach ( $variation_ids as $variation_id ) {
+ $legacy_gallery_image_ids = wp_parse_id_list( get_post_meta( $variation_id, $legacy_meta_key, true ) );
+ $core_gallery_image_ids = wp_parse_id_list( get_post_meta( $variation_id, $core_gallery_meta, true ) );
+
+ if ( empty( $core_gallery_image_ids ) && ! empty( $legacy_gallery_image_ids ) ) {
+ update_post_meta( $variation_id, $core_gallery_meta, implode( ',', $legacy_gallery_image_ids ) );
+ }
+
+ // Keep legacy meta for third-party readers; disable fallback via the sentinel instead.
+ LegacyVariationGalleryCompatibility::mark_variation_id_core_managed( $variation_id );
+ }
+
+ $has_more = ! empty( $select_variation_ids( 1 ) );
+
+ // Guard against duplicate completion events if this runner is invoked twice.
+ if ( ! $has_more && ! get_option( self::COMPLETED_OPTION ) ) {
+ update_option( self::COMPLETED_OPTION, time() );
+ Telemetry::record_event( Telemetry::EVENT_MIGRATION_COMPLETED );
+ }
+
+ return $has_more;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/Package.php b/plugins/woocommerce/src/Internal/VariationGallery/Package.php
new file mode 100644
index 00000000000..efeb7188d72
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/Package.php
@@ -0,0 +1,130 @@
+<?php
+/**
+ * Package class file for the variation gallery feature.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Variation gallery package entry point.
+ *
+ * Registered in `\Automattic\WooCommerce\Packages::$merged_packages` against
+ * the `woocommerce-additional-variation-images` slug, so this class is the
+ * single bootstrap surface for the merged variation gallery feature.
+ */
+class Package {
+
+ /**
+ * Action Scheduler hook for DB update callbacks.
+ */
+ private const UPDATE_CALLBACK_HOOK = 'woocommerce_run_update_callback';
+
+ /**
+ * Action Scheduler group for DB update callbacks.
+ */
+ private const UPDATE_CALLBACK_GROUP = 'woocommerce-db-updates';
+
+ /**
+ * The feature id used by `FeaturesController` (Settings → Advanced → Features).
+ */
+ public const FEATURE_ID = 'variation_gallery';
+
+ /**
+ * Option backing the variation gallery feature toggle.
+ */
+ public const ENABLE_OPTION_NAME = 'wc_feature_woocommerce_additional_variation_images_enabled';
+
+ /**
+ * Whether the merged variation gallery feature is enabled for the current
+ * request.
+ *
+ * Reads the same option as the Features toggles, so the `FeaturesController`
+ * and the merged-package machinery share a single source of truth. Defaults
+ * to off for the 10.9 canary period.
+ *
+ * @return bool
+ */
+ public static function is_enabled() {
+ return 'yes' === get_option( self::ENABLE_OPTION_NAME, 'no' );
+ }
+
+ /**
+ * Early bootstrap hook fired by `Packages::prepare_packages` at
+ * plugins_loaded priority -100. No-op for the variation gallery feature.
+ *
+ * @internal
+ */
+ public static function prepare(): void {
+ }
+
+ /**
+ * Initialize the merged variation gallery feature.
+ *
+ * @internal
+ */
+ final public static function init(): void {
+ if ( ! self::is_enabled() ) {
+ return;
+ }
+
+ $container = wc_get_container();
+ $container->get( ClassicVariationGalleryAdmin::class )->register();
+ $container->get( LegacyVariationGalleryCompatibility::class )->register();
+
+ // Action Scheduler initializes on `init`, not `plugins_loaded`.
+ add_action( 'init', array( __CLASS__, 'maybe_schedule_migration' ), 20 );
+ }
+
+ /**
+ * Schedule the legacy variation gallery migration if it hasn't already
+ * completed and isn't already pending or running.
+ *
+ * @internal
+ */
+ public static function maybe_schedule_migration(): void {
+ if ( get_option( Migration::COMPLETED_OPTION ) ) {
+ return;
+ }
+
+ $args = array( 'update_callback' => array( Migration::class, 'run' ) );
+
+ if ( self::has_pending_or_running_migration( $args ) ) {
+ return;
+ }
+
+ WC()->queue()->add(
+ self::UPDATE_CALLBACK_HOOK,
+ $args,
+ self::UPDATE_CALLBACK_GROUP
+ );
+ }
+
+ /**
+ * Determine whether the migration is already pending or running.
+ *
+ * @param array<string, array<int, string>> $args Exact callback args for the migration action.
+ * @return bool
+ */
+ private static function has_pending_or_running_migration( array $args ): bool {
+ if ( null !== WC()->queue()->get_next( self::UPDATE_CALLBACK_HOOK, $args, self::UPDATE_CALLBACK_GROUP ) ) {
+ return true;
+ }
+
+ $running_actions = WC()->queue()->search(
+ array(
+ 'hook' => self::UPDATE_CALLBACK_HOOK,
+ 'args' => $args,
+ 'status' => \ActionScheduler_Store::STATUS_RUNNING,
+ 'per_page' => 1,
+ 'group' => self::UPDATE_CALLBACK_GROUP,
+ ),
+ 'ids'
+ );
+
+ return ! empty( $running_actions );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/Telemetry.php b/plugins/woocommerce/src/Internal/VariationGallery/Telemetry.php
new file mode 100644
index 00000000000..ff706ccb4e7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/Telemetry.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * Telemetry for the variation gallery feature.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Tracker snapshot + Tracks events for the merged variation gallery feature.
+ */
+class Telemetry implements RegisterHooksInterface {
+
+ public const EVENT_SAVE_SUCCEEDED = 'variation_gallery_save_succeeded';
+ public const EVENT_SAVE_FAILED = 'variation_gallery_save_failed';
+ public const EVENT_MIGRATION_COMPLETED = 'variation_gallery_migration_completed';
+
+ private const LEGACY_PLUGIN_FILE = 'woocommerce-additional-variation-images/woocommerce-additional-variation-images.php';
+
+ /**
+ * Register the tracker snapshot filter.
+ *
+ * @return void
+ */
+ public function register() {
+ add_filter( 'woocommerce_tracker_data', array( $this, 'add_snapshot_to_tracker_data' ), 10, 1 );
+ }
+
+ /**
+ * Append the variation gallery snapshot fields to WC_Tracker's payload.
+ *
+ * @param array $data The aggregated tracker data.
+ * @return array
+ */
+ public function add_snapshot_to_tracker_data( array $data ): array {
+ $data['variation_gallery'] = self::collect_snapshot();
+ return $data;
+ }
+
+ /**
+ * Collect the variation gallery snapshot fields.
+ *
+ * @return array<string, mixed>
+ */
+ public static function collect_snapshot(): array {
+ global $wpdb;
+
+ $option_value = get_option( Package::ENABLE_OPTION_NAME, '' );
+ $variant_assignment = (int) get_option( 'woocommerce_remote_variant_assignment', 0 );
+ $cohort = ( $variant_assignment > 0 && $variant_assignment <= 5 ) ? 'treatment' : 'control';
+ $legacy_plugin_active = self::is_legacy_plugin_active();
+ $legacy_plugin_file = WP_PLUGIN_DIR . '/' . self::LEGACY_PLUGIN_FILE;
+
+ $migrated_variation_count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s",
+ LegacyVariationGalleryCompatibility::get_core_managed_meta_key()
+ )
+ );
+
+ $variation_gallery_rows = $wpdb->get_col(
+ "SELECT pm.meta_value
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE pm.meta_key = '_product_image_gallery'
+ AND pm.meta_value <> ''
+ AND p.post_type = 'product_variation'"
+ );
+
+ $authored_variation_count = 0;
+ $total_image_count = 0;
+ $single_image_variation_count = 0;
+ $multi_image_variation_count = 0;
+
+ foreach ( $variation_gallery_rows as $meta_value ) {
+ $image_ids = wp_parse_id_list( $meta_value );
+ $count = count( $image_ids );
+ if ( 0 === $count ) {
+ continue;
+ }
+ ++$authored_variation_count;
+ $total_image_count += $count;
+ if ( 1 === $count ) {
+ ++$single_image_variation_count;
+ } else {
+ ++$multi_image_variation_count;
+ }
+ }
+
+ $authored_without_legacy_count = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$wpdb->postmeta} core_pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = core_pm.post_id
+ LEFT JOIN {$wpdb->postmeta} legacy_pm
+ ON legacy_pm.post_id = core_pm.post_id
+ AND legacy_pm.meta_key = '_wc_additional_variation_images'
+ WHERE core_pm.meta_key = '_product_image_gallery'
+ AND core_pm.meta_value <> ''
+ AND p.post_type = 'product_variation'
+ AND legacy_pm.post_id IS NULL"
+ );
+
+ return array(
+ 'feature_enabled' => 'yes' === $option_value ? 'yes' : 'no',
+ 'feature_option_explicit' => '' === $option_value ? 'no' : 'yes',
+ 'remote_variant_cohort' => $cohort,
+ 'legacy_avi_plugin_active' => $legacy_plugin_active ? 'yes' : 'no',
+ 'legacy_avi_plugin_installed' => file_exists( $legacy_plugin_file ) ? 'yes' : 'no',
+ 'migrated_variation_count' => $migrated_variation_count,
+ 'authored_variation_count' => $authored_variation_count,
+ 'authored_without_legacy_count' => $authored_without_legacy_count,
+ 'total_image_count' => $total_image_count,
+ 'single_image_variation_count' => $single_image_variation_count,
+ 'multi_image_variation_count' => $multi_image_variation_count,
+ );
+ }
+
+ /**
+ * Record a Tracks event.
+ *
+ * @param string $event_name One of the `EVENT_*` class constants.
+ * @param array<string, mixed> $properties Event properties to attach.
+ * @return void
+ */
+ public static function record_event( string $event_name, array $properties = array() ): void {
+ if ( ! function_exists( 'wc_admin_record_tracks_event' ) ) {
+ return;
+ }
+ wc_admin_record_tracks_event( $event_name, $properties );
+ }
+
+ /**
+ * Whether the legacy Additional Variation Images extension is currently
+ * active.
+ *
+ * @return bool
+ */
+ private static function is_legacy_plugin_active(): bool {
+ if ( ! function_exists( 'is_plugin_active' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ return is_plugin_active( self::LEGACY_PLUGIN_FILE );
+ }
+}
diff --git a/plugins/woocommerce/src/Packages.php b/plugins/woocommerce/src/Packages.php
index 736196444df..9952fd64b69 100644
--- a/plugins/woocommerce/src/Packages.php
+++ b/plugins/woocommerce/src/Packages.php
@@ -60,7 +60,8 @@ class Packages {
* @var array Key is the package name/directory, value is the main package class which handles init.
*/
protected static $merged_packages = array(
- 'woocommerce-brands' => '\\Automattic\\WooCommerce\\Internal\\Brands',
+ 'woocommerce-brands' => '\\Automattic\\WooCommerce\\Internal\\Brands',
+ 'woocommerce-additional-variation-images' => '\\Automattic\\WooCommerce\\Internal\\VariationGallery\\Package',
);
diff --git a/plugins/woocommerce/templates/single-product/add-to-cart/variable.php b/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
index d35c19a7179..e2291b43b14 100644
--- a/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
+++ b/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 9.6.0
+ * @version 10.9.0
*/
defined( 'ABSPATH' ) || exit;
@@ -60,6 +60,14 @@ do_action( 'woocommerce_before_add_to_cart_form' ); ?>
</tbody>
</table>
<div class="reset_variations_alert screen-reader-text" role="alert" aria-live="polite" aria-relevant="all"></div>
+ <?php
+ // Reset snapshot for cases where a theme/plugin loads the variation form later, like quick-view modals.
+ if ( \Automattic\WooCommerce\Internal\VariationGallery\Package::is_enabled() ) :
+ ?>
+ <script type="text/template" class="wc-product-gallery-default-template"><?php echo wc_get_product_gallery_html( $product ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></script>
+ <?php
+ endif;
+ ?>
<?php do_action( 'woocommerce_after_variations_table' ); ?>
<div class="single_variation_wrap">
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
index be52ae02db8..be67947956d 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
@@ -400,7 +400,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
$data = $response->get_data();
$properties = $data['schema']['properties'];
- $this->assertEquals( 41, count( $properties ) );
+ $this->assertEquals( 42, count( $properties ) );
$this->assertArrayHasKey( 'id', $properties );
$this->assertArrayHasKey( 'date_created', $properties );
$this->assertArrayHasKey( 'date_modified', $properties );
@@ -434,6 +434,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'shipping_class', $properties );
$this->assertArrayHasKey( 'shipping_class_id', $properties );
$this->assertArrayHasKey( 'image', $properties );
+ $this->assertArrayHasKey( 'gallery_image_ids', $properties );
$this->assertArrayHasKey( 'attributes', $properties );
$this->assertArrayHasKey( 'menu_order', $properties );
$this->assertArrayHasKey( 'meta_data', $properties );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
index c6bf23364f6..7e8a2716c8d 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
@@ -4,6 +4,14 @@
* Tests for WC_Product_Variable.
*/
class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
+ /**
+ * Reset variation gallery feature-flag option leaked by individual tests.
+ */
+ public function tearDown(): void {
+ delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+ parent::tearDown();
+ }
+
/**
* @testdox 'get_available_variations' returns the variations as arrays if no parameters is passed.
*/
@@ -162,4 +170,176 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
$this->assertIsBool( $has_purchasable_variations );
$this->assertFalse( $has_purchasable_variations );
}
+
+ /**
+ * @testdox 'get_available_variation' exposes typed variation gallery image IDs.
+ */
+ public function test_get_available_variation_includes_gallery_image_ids() {
+ update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+ $product = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $product->get_children()[0] );
+ $image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $image_ids = array(
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 1',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 2',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ );
+
+ update_post_meta( $image_id, '_wp_attached_file', 'variation-featured.jpg' );
+ foreach ( $image_ids as $i => $gallery_image_id ) {
+ update_post_meta( $gallery_image_id, '_wp_attached_file', 'variation-gallery-' . ( $i + 1 ) . '.jpg' );
+ }
+
+ $variation->set_image_id( $image_id );
+ $variation->set_gallery_image_ids( $image_ids );
+ $variation->save();
+
+ $available_variation = $product->get_available_variation( $variation );
+
+ $this->assertSame( $image_ids, $available_variation['gallery_image_ids'] );
+ $this->assertNotEmpty( $available_variation['gallery_images_html'] );
+ }
+
+ /**
+ * @testdox 'get_available_variation' omits multi-image gallery data when the variation gallery feature flag is disabled.
+ */
+ public function test_get_available_variation_returns_single_image_shape_when_feature_flag_disabled() {
+ update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'no' );
+
+ $product = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $product->get_children()[0] );
+ $image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $image_ids = array(
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 1',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 2',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ );
+
+ update_post_meta( $image_id, '_wp_attached_file', 'variation-disabled.jpg' );
+
+ $variation->set_image_id( $image_id );
+ $variation->set_gallery_image_ids( $image_ids );
+ $variation->save();
+
+ $available_variation = $product->get_available_variation( $variation );
+
+ $this->assertSame( array(), $available_variation['gallery_image_ids'] );
+ $this->assertSame( '', $available_variation['gallery_images_html'] );
+ $this->assertSame( $image_id, $available_variation['image_id'] );
+ }
+
+ /**
+ * @testdox 'get_available_variation' falls back to the variation's own gallery when the variation featured image is stale.
+ */
+ public function test_get_available_variation_falls_back_to_variation_gallery_when_featured_is_stale() {
+ update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+ $product = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $product->get_children()[0] );
+ $parent_featured_id = $this->create_image_attachment( 'Parent Featured Image', 'parent-featured.jpg' );
+ $stale_featured_id = $this->create_image_attachment( 'Stale Variation Image', 'stale-featured.jpg' );
+ $variation_gallery_id = $this->create_image_attachment( 'Variation Gallery Image', 'variation-gallery.jpg' );
+
+ // Delete-then-assign: set_image_id() doesn't validate the attachment,
+ // but wp_delete_attachment() would clear _thumbnail_id on any post
+ // pointing at it. Doing it in this order leaves the variation
+ // referencing a deleted attachment, which is the bug we're testing.
+
+ $product->set_image_id( $parent_featured_id );
+ $product->save();
+
+ wp_delete_attachment( $stale_featured_id, true );
+
+ $variation->set_image_id( $stale_featured_id );
+ $variation->set_gallery_image_ids( array( $variation_gallery_id ) );
+ $variation->save();
+
+ $available_variation = $product->get_available_variation( $variation );
+
+ $this->assertSame( $variation_gallery_id, $available_variation['image_id'] );
+ $this->assertStringContainsString( 'wp-image-' . $variation_gallery_id, $available_variation['gallery_images_html'] );
+ $this->assertStringNotContainsString( 'wp-image-' . $parent_featured_id, $available_variation['gallery_images_html'] );
+ }
+
+ /**
+ * @testdox 'get_available_variation' falls back to the parent featured image when both the variation featured image and gallery are absent.
+ */
+ public function test_get_available_variation_falls_back_to_parent_featured_when_variation_has_no_images() {
+ update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+ $product = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $product->get_children()[0] );
+ $parent_featured_id = $this->create_image_attachment( 'Parent Featured Image', 'parent-featured.jpg' );
+ $stale_featured_id = $this->create_image_attachment( 'Stale Variation Image', 'stale-featured.jpg' );
+
+ $product->set_image_id( $parent_featured_id );
+ $product->save();
+
+ wp_delete_attachment( $stale_featured_id, true );
+
+ $variation->set_image_id( $stale_featured_id );
+ $variation->set_gallery_image_ids( array() );
+ $variation->save();
+
+ $available_variation = $product->get_available_variation( $variation );
+
+ $this->assertSame( $parent_featured_id, $available_variation['image_id'] );
+ $this->assertSame( '', $available_variation['gallery_images_html'] );
+ }
+
+ /**
+ * Create a real image attachment that passes `wp_attachment_is_image()`.
+ *
+ * @param string $title Post title.
+ * @param string $attached_file Synthetic file path.
+ * @return int
+ */
+ private function create_image_attachment( string $title, string $attached_file ): int {
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => $title,
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+
+ update_post_meta( $attachment_id, '_wp_attached_file', $attached_file );
+
+ return $attachment_id;
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index 3482165fc2e..ac7db2c9eb5 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -168,6 +168,131 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_REST_Unit_Test_Case
$this->assertEquals( 200, $response->get_status() );
}
+ /**
+ * @testdox The variation GET endpoint returns typed gallery image IDs.
+ */
+ public function test_variation_get_returns_gallery_image_ids() {
+ $parent_product = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $parent_product->get_children()[0] );
+ $image_ids = array(
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 1',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 2',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ );
+
+ $variation->set_gallery_image_ids( $image_ids );
+ $variation->save();
+
+ $response = $this->server->dispatch(
+ new WP_REST_Request(
+ 'GET',
+ "/wc/v3/products/{$parent_product->get_id()}/variations/{$variation->get_id()}"
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertSame( $image_ids, $response->get_data()['gallery_image_ids'] );
+ }
+
+ /**
+ * @testdox The variation POST endpoint updates the gallery without touching the featured image (disjoint storage).
+ */
+ public function test_variation_post_updates_gallery_image_ids() {
+ $parent_product = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $parent_product->get_children()[0] );
+ $pre_existing_feature = wp_insert_attachment(
+ array(
+ 'post_title' => 'Pre-existing featured image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $variation->set_image_id( $pre_existing_feature );
+ $variation->save();
+
+ $image_ids = array(
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 1',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 2',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ );
+
+ $this->update_variation_via_post_request(
+ $variation,
+ array(
+ 'gallery_image_ids' => $image_ids,
+ )
+ );
+
+ $variation = wc_get_product( $variation->get_id() );
+
+ $this->assertSame( $image_ids, array_map( 'intval', $variation->get_gallery_image_ids() ) );
+ $this->assertSame(
+ $pre_existing_feature,
+ $variation->get_image_id(),
+ 'Setting gallery_image_ids must not touch the featured image — they are disjoint, like on parent products.'
+ );
+ // No legacy meta exists, so the core-managed sentinel is intentionally
+ // not written: the legacy fallback would no-op anyway, and skipping the
+ // row keeps postmeta clean on stores that never used the extension.
+ $this->assertFalse(
+ \Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility::is_variation_id_core_managed(
+ $variation->get_id()
+ )
+ );
+ }
+
+ /**
+ * @testdox The variation POST endpoint can clear a legacy-only variation gallery.
+ */
+ public function test_variation_post_can_clear_legacy_gallery_image_ids() {
+ $parent_product = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $parent_product->get_children()[0] );
+
+ update_post_meta(
+ $variation->get_id(),
+ '_wc_additional_variation_images',
+ '101,102'
+ );
+
+ $this->update_variation_via_post_request(
+ $variation,
+ array(
+ 'gallery_image_ids' => array(),
+ )
+ );
+
+ $variation = wc_get_product( $variation->get_id() );
+
+ $this->assertSame( array(), $variation->get_gallery_image_ids() );
+ $this->assertTrue(
+ \Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility::is_variation_id_core_managed(
+ $variation->get_id()
+ )
+ );
+ }
+
/**
* Test that creating a variation with attributes containing special characters in their slug
* properly saves the attributes.
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index 5d538dc69a2..3d5cf9f766d 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -16,6 +16,15 @@ use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;
*/
class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
+ /**
+ * Reset the variation gallery feature-flag option after each test so
+ * individual cases that flip it on don't leak global state.
+ */
+ public function tearDown(): void {
+ delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+ parent::tearDown();
+ }
+
/**
* @testdox If 'wc_get_price_excluding_tax' gets an order as argument, it passes the order customer to 'WC_Tax::get_rates'.
*
@@ -1019,4 +1028,62 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
WC_Helper_Product::delete_product( $product->get_id() );
}
+
+ /**
+ * @testdox Variable add-to-cart attaches a pristine gallery snapshot to the variation script when the feature is on.
+ */
+ public function test_woocommerce_variable_add_to_cart_attaches_gallery_snapshot() {
+ update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+ $inline_js = $this->capture_variable_add_to_cart_inline_js();
+
+ $this->assertStringContainsString( 'wc_variation_gallery_defaults', $inline_js );
+
+ // Extract the JSON snapshot from the inline JS and verify it contains gallery markup.
+ preg_match( '/\)\[\d+\]\s*=\s*("(?:\\\\.|[^"])*");/', $inline_js, $matches );
+ $this->assertNotEmpty( $matches, 'Inline JS should expose a JSON-encoded snapshot.' );
+ $decoded_snapshot = json_decode( $matches[1] );
+ $this->assertIsString( $decoded_snapshot );
+ $this->assertStringContainsString( 'woocommerce-product-gallery', $decoded_snapshot );
+ }
+
+ /**
+ * @testdox Variable add-to-cart skips the gallery snapshot when the feature is off.
+ */
+ public function test_woocommerce_variable_add_to_cart_skips_gallery_snapshot_when_feature_off() {
+ delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+
+ $inline_js = $this->capture_variable_add_to_cart_inline_js();
+
+ $this->assertStringNotContainsString( 'wc_variation_gallery_defaults', $inline_js );
+ }
+
+ /**
+ * Render the variable add-to-cart template and return the inline JS
+ * attached to the variation script.
+ */
+ private function capture_variable_add_to_cart_inline_js(): string {
+ $product = WC_Helper_Product::create_variation_product();
+
+ WC_Frontend_Scripts::load_scripts();
+
+ $wp_scripts = wp_scripts();
+ if ( isset( $wp_scripts->registered['wc-add-to-cart-variation'] ) ) {
+ unset( $wp_scripts->registered['wc-add-to-cart-variation']->extra['before'] );
+ }
+
+ $previous_product = $GLOBALS['product'] ?? null;
+ $GLOBALS['product'] = $product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ ob_start();
+ woocommerce_variable_add_to_cart();
+ ob_end_clean();
+
+ $before_data = $wp_scripts->registered['wc-add-to-cart-variation']->extra['before'] ?? array();
+
+ $GLOBALS['product'] = $previous_product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ WC_Helper_Product::delete_product( $product->get_id() );
+
+ return implode( "\n", (array) $before_data );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
index cd9e95bd2a8..128fe7c1cb3 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
@@ -9,10 +9,20 @@ use WP_UnitTestCase;
* Tests for the ProductGalleryUtils class.
*/
class ProductGalleryUtilsTest extends \WP_UnitTestCase {
+ /**
+ * Reset variation gallery feature-flag option leaked by individual tests.
+ */
+ public function tearDown(): void {
+ delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+ parent::tearDown();
+ }
+
/**
* Test get_product_gallery_image_data method.
*/
public function test_get_product_gallery_image_data() {
+ update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
// Create the variable product.
$variable_product = \WC_Helper_Product::create_variation_product();
@@ -25,6 +35,7 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
)
);
$variable_product->set_image_id( $image_id );
+ update_post_meta( $image_id, '_wp_attached_file', 'product-featured.jpg' );
// Create a variation image but don't add it to the gallery.
$variation_image_id = wp_insert_attachment(
@@ -34,6 +45,7 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
'post_mime_type' => 'image/jpeg',
)
);
+ update_post_meta( $variation_image_id, '_wp_attached_file', 'variation-featured.jpg' );
// Get the variations.
$variations = $variable_product->get_children();
@@ -62,6 +74,34 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
);
$variable_product->set_gallery_image_ids( $gallery_image_ids );
$variable_product->save();
+ foreach ( $gallery_image_ids as $i => $gallery_image_id ) {
+ update_post_meta( $gallery_image_id, '_wp_attached_file', 'product-gallery-' . ( $i + 1 ) . '.jpg' );
+ }
+
+ $variation_gallery_image_ids = array(
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 1',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 2',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ );
+ foreach ( $variation_gallery_image_ids as $i => $variation_gallery_image_id ) {
+ update_post_meta( $variation_gallery_image_id, '_wp_attached_file', 'variation-gallery-' . ( $i + 1 ) . '.jpg' );
+ }
+
+ if ( isset( $variation ) ) {
+ $variation->set_gallery_image_ids( $variation_gallery_image_ids );
+ $variation->save();
+ }
$image_data = ProductGalleryUtils::get_product_gallery_image_data( $variable_product, 'woocommerce_thumbnail' );
@@ -81,6 +121,9 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
// Assert that the child product image is included in the image data array.
$ids = array_column( $image_data, 'id' );
$this->assertContains( $variation_image_id, $ids );
+ foreach ( $variation_gallery_image_ids as $variation_gallery_image_id ) {
+ $this->assertContains( $variation_gallery_image_id, $ids );
+ }
// Clean up.
$variable_product->delete( true );
@@ -89,5 +132,110 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
foreach ( $gallery_image_ids as $gallery_image_id ) {
wp_delete_attachment( $gallery_image_id, true );
}
+ foreach ( $variation_gallery_image_ids as $variation_gallery_image_id ) {
+ wp_delete_attachment( $variation_gallery_image_id, true );
+ }
+ }
+
+ /**
+ * Test that get_product_variation_gallery_data returns the single-image
+ * shape when the variation gallery feature flag is disabled, even when
+ * the variation has multiple gallery images saved.
+ */
+ public function test_get_product_variation_gallery_data_returns_single_image_when_feature_flag_disabled() {
+ update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'no' );
+
+ $variable_product = \WC_Helper_Product::create_variation_product();
+
+ $variation_image_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Featured Image',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ update_post_meta( $variation_image_id, '_wp_attached_file', 'variation-featured.jpg' );
+
+ $variation_gallery_image_ids = array(
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 1',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ wp_insert_attachment(
+ array(
+ 'post_title' => 'Variation Gallery Image 2',
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ ),
+ );
+
+ $variation = wc_get_product( $variable_product->get_children()[0] );
+ $variation->set_image_id( $variation_image_id );
+ $variation->set_gallery_image_ids( $variation_gallery_image_ids );
+ $variation->save();
+
+ $variation_entry = ProductGalleryUtils::get_product_variation_gallery_data( $variable_product )[ $variation->get_id() ];
+
+ $this->assertSame( $variation_image_id, $variation_entry['image_id'] );
+ $this->assertSame( array( $variation_image_id ), $variation_entry['image_ids'] );
+ }
+
+ /**
+ * Test that variation gallery data falls back to the variation's own gallery when the variation featured image is stale.
+ */
+ public function test_get_product_variation_gallery_data_falls_back_to_variation_gallery_when_featured_is_stale() {
+ update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+ $variable_product = \WC_Helper_Product::create_variation_product();
+ $parent_featured_id = $this->create_image_attachment( 'Parent Featured Image', 'parent-featured.jpg' );
+ $stale_featured_id = $this->create_image_attachment( 'Stale Variation Image', 'stale-featured.jpg' );
+ $variation_gallery_id = $this->create_image_attachment( 'Variation Gallery Image', 'variation-gallery.jpg' );
+
+ $variable_product->set_image_id( $parent_featured_id );
+ $variable_product->save();
+
+ $variation = wc_get_product( $variable_product->get_children()[0] );
+
+ // Delete-then-assign: leaves the variation referencing a deleted
+ // attachment (the bug we're testing). Reversing the order would let
+ // wp_delete_attachment clear _thumbnail_id automatically.
+ wp_delete_attachment( $stale_featured_id, true );
+
+ $variation->set_image_id( $stale_featured_id );
+ $variation->set_gallery_image_ids( array( $variation_gallery_id ) );
+ $variation->save();
+
+ $variation_gallery_data = ProductGalleryUtils::get_product_variation_gallery_data( $variable_product );
+
+ $this->assertSame( $variation_gallery_id, $variation_gallery_data[ $variation->get_id() ]['image_id'] );
+ $this->assertSame(
+ array( $variation_gallery_id ),
+ $variation_gallery_data[ $variation->get_id() ]['image_ids']
+ );
+ }
+
+ /**
+ * Create a real image attachment that passes `wp_attachment_is_image()`.
+ *
+ * @param string $title Post title.
+ * @param string $attached_file Synthetic file path.
+ * @return int
+ */
+ private function create_image_attachment( string $title, string $attached_file ): int {
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => $title,
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+
+ update_post_meta( $attachment_id, '_wp_attached_file', $attached_file );
+
+ return $attachment_id;
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/VariationGallery/ClassicVariationGalleryAdminTest.php b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/ClassicVariationGalleryAdminTest.php
new file mode 100644
index 00000000000..b9681cf1628
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/ClassicVariationGalleryAdminTest.php
@@ -0,0 +1,215 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\VariationGallery\ClassicVariationGalleryAdmin;
+use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
+use WC_Helper_Product;
+use WC_Product_Variation;
+
+/**
+ * Tests for ClassicVariationGalleryAdmin.
+ */
+class ClassicVariationGalleryAdminTest extends \WC_Unit_Test_Case {
+
+ /**
+ * System under test.
+ *
+ * @var ClassicVariationGalleryAdmin
+ */
+ private $sut;
+
+ /**
+ * Set up test dependencies.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new ClassicVariationGalleryAdmin();
+ }
+
+ /**
+ * Reset globals after each test.
+ */
+ public function tearDown(): void {
+ unset( $_POST['variable_gallery_image_ids'] );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox The classic variation gallery field renders legacy images via the runtime fallback.
+ */
+ public function test_render_variation_gallery_field_renders_legacy_images() {
+ $variation = $this->create_variation();
+ $image_ids = array(
+ $this->create_attachment( 'Legacy gallery image 1' ),
+ $this->create_attachment( 'Legacy gallery image 2' ),
+ );
+
+ update_post_meta(
+ $variation->get_id(),
+ '_wc_additional_variation_images',
+ implode( ',', $image_ids )
+ );
+
+ ob_start();
+ $this->sut->render_variation_gallery_field( 0, array(), get_post( $variation->get_id() ) );
+ $output = (string) ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'value="' . implode( ',', $image_ids ) . '"',
+ $output
+ );
+ }
+
+ /**
+ * @testdox A featured image absent from the gallery gets prepended in the editor view so the first save normalizes storage.
+ */
+ public function test_render_variation_gallery_field_prepends_featured_image_when_missing_from_gallery() {
+ $variation = $this->create_variation();
+ $featured = $this->create_attachment( 'Featured-only image' );
+ $gallery = $this->create_attachment( 'Gallery-only image' );
+
+ $variation->set_image_id( $featured );
+ $variation->set_gallery_image_ids( array( $gallery ) );
+ $variation->save();
+
+ ob_start();
+ $this->sut->render_variation_gallery_field( 0, array(), get_post( $variation->get_id() ) );
+ $output = (string) ob_get_clean();
+
+ // Featured image must be prepended to the list (value) AND get the active class.
+ $this->assertStringContainsString( 'value="' . $featured . ',' . $gallery . '"', $output );
+ $this->assertMatchesRegularExpression(
+ '/<li\s+class="wc-variation-gallery-thumb is-active"\s+data-attachment_id="' . $featured . '"/',
+ $output
+ );
+ }
+
+ /**
+ * @testdox Broken attachments are kept in the rendered list instead of silently dropped.
+ */
+ public function test_render_variation_gallery_field_keeps_broken_attachments() {
+ $variation = $this->create_variation();
+ $missing = 99999;
+ $good = $this->create_attachment( 'Intact gallery image' );
+
+ $variation->set_gallery_image_ids( array( $missing, $good ) );
+ $variation->save();
+
+ ob_start();
+ $this->sut->render_variation_gallery_field( 0, array(), get_post( $variation->get_id() ) );
+ $output = (string) ob_get_clean();
+
+ $this->assertStringContainsString(
+ 'data-attachment_id="' . $missing . '"',
+ $output
+ );
+ }
+
+ /**
+ * @testdox Empty variation rows render the hidden gallery input needed for the first save.
+ */
+ public function test_render_variation_gallery_field_renders_hidden_input_for_empty_gallery() {
+ $variation = $this->create_variation();
+
+ ob_start();
+ $this->sut->render_variation_gallery_field( 0, array(), get_post( $variation->get_id() ) );
+ $output = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'name="variable_gallery_image_ids[0]"', $output );
+ $this->assertStringContainsString( 'value=""', $output );
+ }
+
+ /**
+ * @testdox Saving an empty variation gallery clears featured + gallery and disables the legacy fallback.
+ */
+ public function test_saving_empty_variation_gallery_disables_legacy_fallback() {
+ $variation = $this->create_variation();
+ $pre_existing_featured = $this->create_attachment( 'Pre-existing featured image' );
+ $image_ids = array(
+ $this->create_attachment( 'Legacy gallery image 1' ),
+ $this->create_attachment( 'Legacy gallery image 2' ),
+ );
+
+ // Pre-set a featured image so we can assert the empty save also clears it.
+ $variation->set_image_id( $pre_existing_featured );
+ $variation->save();
+
+ update_post_meta(
+ $variation->get_id(),
+ '_wc_additional_variation_images',
+ implode( ',', $image_ids )
+ );
+
+ $_POST['variable_gallery_image_ids'][0] = '';
+
+ $this->sut->persist_variation_gallery_field( $variation, 0 );
+ $variation->save();
+
+ $reloaded_variation = wc_get_product( $variation->get_id() );
+
+ $this->assertSame( 0, $reloaded_variation->get_image_id( 'edit' ), 'Featured image should be cleared on empty save.' );
+ $this->assertSame( array(), $reloaded_variation->get_gallery_image_ids() );
+ $this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation->get_id() ) );
+ }
+
+ /**
+ * @testdox Saving the unified list assigns position 0 as the featured image and the rest as the gallery (matching parent product semantics).
+ */
+ public function test_saving_variation_gallery_splits_featured_from_gallery() {
+ $variation = $this->create_variation();
+ $image_ids = array(
+ $this->create_attachment( 'Core gallery image 1' ),
+ $this->create_attachment( 'Core gallery image 2' ),
+ $this->create_attachment( 'Core gallery image 3' ),
+ );
+
+ // Includes a non-numeric token plus a duplicate of position 0 to verify
+ // sanitization survives the split.
+ $_POST['variable_gallery_image_ids'][0] = $image_ids[0] . ',not-an-id,' . $image_ids[1] . ',' . $image_ids[2] . ',' . $image_ids[0];
+
+ $this->sut->persist_variation_gallery_field( $variation, 0 );
+ $variation->save();
+
+ $reloaded_variation = wc_get_product( $variation->get_id() );
+
+ $this->assertSame(
+ $image_ids[0],
+ $reloaded_variation->get_image_id( 'edit' ),
+ 'Position 0 of the unified list should become the featured image.'
+ );
+ $this->assertSame(
+ array( $image_ids[1], $image_ids[2] ),
+ array_values( array_map( 'intval', $reloaded_variation->get_gallery_image_ids( 'edit' ) ) ),
+ 'Subsequent positions should become the gallery, disjoint from featured.'
+ );
+ }
+
+ /**
+ * Create a variation for testing.
+ *
+ * @return WC_Product_Variation
+ */
+ private function create_variation(): WC_Product_Variation {
+ $product = WC_Helper_Product::create_variation_product();
+
+ return wc_get_product( $product->get_children()[0] );
+ }
+
+ /**
+ * Create a test attachment.
+ *
+ * @param string $title Attachment title.
+ * @return int
+ */
+ private function create_attachment( string $title ): int {
+ return wp_insert_attachment(
+ array(
+ 'post_title' => $title,
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/VariationGallery/LegacyVariationGalleryCompatibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/LegacyVariationGalleryCompatibilityTest.php
new file mode 100644
index 00000000000..6343a49f5ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/LegacyVariationGalleryCompatibilityTest.php
@@ -0,0 +1,141 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
+use WC_Helper_Product;
+use WC_Product_Variation;
+
+/**
+ * Tests for LegacyVariationGalleryCompatibility.
+ */
+class LegacyVariationGalleryCompatibilityTest extends \WC_Unit_Test_Case {
+
+ /**
+ * System under test.
+ *
+ * @var LegacyVariationGalleryCompatibility
+ */
+ private $sut;
+
+ /**
+ * Set up test dependencies.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new LegacyVariationGalleryCompatibility();
+ }
+
+ /**
+ * Remove registered filters after each test.
+ */
+ public function tearDown(): void {
+ remove_filter(
+ 'woocommerce_product_variation_get_gallery_image_ids',
+ array( $this->sut, 'maybe_read_legacy_gallery_image_ids' ),
+ 10
+ );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Legacy variation gallery meta is ignored when the core gallery already has values.
+ */
+ public function test_legacy_gallery_meta_is_ignored_when_core_gallery_is_present() {
+ $variation = $this->create_variation();
+ $core_gallery_ids = array(
+ $this->create_attachment( 'Core variation gallery 1' ),
+ $this->create_attachment( 'Core variation gallery 2' ),
+ );
+ $legacy_gallery_ids = array(
+ $this->create_attachment( 'Legacy variation gallery 1' ),
+ $this->create_attachment( 'Legacy variation gallery 2' ),
+ );
+
+ update_post_meta(
+ $variation->get_id(),
+ '_wc_additional_variation_images',
+ implode( ',', $legacy_gallery_ids )
+ );
+
+ $this->assertSame(
+ $core_gallery_ids,
+ $this->sut->maybe_read_legacy_gallery_image_ids(
+ $core_gallery_ids,
+ $variation
+ )
+ );
+ }
+
+ /**
+ * @testdox Legacy variation gallery meta is ignored when fallback has been disabled.
+ */
+ public function test_legacy_gallery_meta_is_ignored_when_fallback_has_been_disabled() {
+ $variation = $this->create_variation();
+ $image_ids = array(
+ $this->create_attachment( 'Legacy variation gallery 1' ),
+ $this->create_attachment( 'Legacy variation gallery 2' ),
+ );
+
+ update_post_meta(
+ $variation->get_id(),
+ '_wc_additional_variation_images',
+ implode( ',', $image_ids )
+ );
+ LegacyVariationGalleryCompatibility::mark_variation_id_core_managed( $variation->get_id() );
+
+ $this->assertSame(
+ array(),
+ $this->sut->maybe_read_legacy_gallery_image_ids( array(), $variation )
+ );
+ }
+
+ /**
+ * @testdox Register wires the legacy gallery fallback into variation prop reads.
+ */
+ public function test_register_wires_legacy_gallery_fallback_into_variation_prop_reads() {
+ $variation = $this->create_variation();
+ $image_ids = array(
+ $this->create_attachment( 'Legacy variation gallery 1' ),
+ $this->create_attachment( 'Legacy variation gallery 2' ),
+ );
+
+ update_post_meta(
+ $variation->get_id(),
+ '_wc_additional_variation_images',
+ implode( ',', $image_ids )
+ );
+
+ $this->sut->register();
+
+ $this->assertSame( $image_ids, array_map( 'intval', $variation->get_gallery_image_ids() ) );
+ }
+
+ /**
+ * Create a variation for testing.
+ *
+ * @return WC_Product_Variation
+ */
+ private function create_variation(): WC_Product_Variation {
+ $product = WC_Helper_Product::create_variation_product();
+
+ return wc_get_product( $product->get_children()[0] );
+ }
+
+ /**
+ * Create a test attachment.
+ *
+ * @param string $title Attachment title.
+ * @return int
+ */
+ private function create_attachment( string $title ): int {
+ return wp_insert_attachment(
+ array(
+ 'post_title' => $title,
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/VariationGallery/MigrationTest.php b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/MigrationTest.php
new file mode 100644
index 00000000000..403c5fca2aa
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/MigrationTest.php
@@ -0,0 +1,146 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
+use Automattic\WooCommerce\Internal\VariationGallery\Migration;
+use WC_Helper_Product;
+
+/**
+ * Tests for the legacy variation gallery migration runner.
+ */
+class MigrationTest extends \WC_Unit_Test_Case {
+
+ /**
+ * @testdox Migration copies legacy variation gallery meta into the core gallery prop and disables fallback.
+ */
+ public function test_migration_copies_legacy_gallery_and_disables_fallback(): void {
+ $variation_id = $this->create_variation();
+ $image_ids = array(
+ $this->create_attachment( 'Legacy gallery image 1' ),
+ $this->create_attachment( 'Legacy gallery image 2' ),
+ );
+
+ update_post_meta( $variation_id, '_wc_additional_variation_images', implode( ',', $image_ids ) );
+
+ $this->assertFalse( Migration::run() );
+
+ $this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) );
+ $this->assertSame( implode( ',', $image_ids ), get_post_meta( $variation_id, '_product_image_gallery', true ) );
+ }
+
+ /**
+ * @testdox Migration preserves existing core variation gallery values while disabling fallback.
+ */
+ public function test_migration_preserves_existing_core_gallery(): void {
+ $variation_id = $this->create_variation();
+ $core_gallery_ids = array(
+ $this->create_attachment( 'Core gallery image 1' ),
+ $this->create_attachment( 'Core gallery image 2' ),
+ );
+ $legacy_gallery_ids = array(
+ $this->create_attachment( 'Legacy gallery image 1' ),
+ $this->create_attachment( 'Legacy gallery image 2' ),
+ );
+
+ update_post_meta( $variation_id, '_product_image_gallery', implode( ',', $core_gallery_ids ) );
+ update_post_meta( $variation_id, '_wc_additional_variation_images', implode( ',', $legacy_gallery_ids ) );
+
+ $this->assertFalse( Migration::run() );
+
+ $this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) );
+ $this->assertSame( implode( ',', $core_gallery_ids ), get_post_meta( $variation_id, '_product_image_gallery', true ) );
+ }
+
+ /**
+ * @testdox Migration disables fallback for malformed legacy variation gallery meta without writing invalid core values.
+ */
+ public function test_migration_disables_fallback_for_malformed_legacy_meta(): void {
+ $variation_id = $this->create_variation();
+
+ update_post_meta( $variation_id, '_wc_additional_variation_images', 'not-an-id' );
+
+ $this->assertFalse( Migration::run() );
+
+ $this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) );
+ $this->assertSame( '', get_post_meta( $variation_id, '_product_image_gallery', true ) );
+ }
+
+ /**
+ * @testdox Migration batches legacy variation gallery rows and requeues until complete.
+ */
+ public function test_migration_batches_updates(): void {
+ global $wpdb;
+
+ // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Test setup needs to scope deletes by meta_key.
+ $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_wc_additional_variation_images' ) );
+ $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_product_image_gallery' ) );
+ $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => LegacyVariationGalleryCompatibility::get_core_managed_meta_key() ) );
+ // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+
+ $variation_ids = array();
+
+ for ( $index = 0; $index < 251; ++$index ) {
+ $variation_id = $this->create_variation_post();
+ $variation_ids[] = $variation_id;
+ update_post_meta( $variation_id, '_wc_additional_variation_images', (string) ( $index + 1 ) );
+ }
+
+ $this->assertTrue( Migration::run() );
+
+ $processed_after_first_batch = 0;
+
+ foreach ( $variation_ids as $variation_id ) {
+ if ( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) ) {
+ ++$processed_after_first_batch;
+ }
+ }
+
+ $this->assertSame( 250, $processed_after_first_batch );
+ $this->assertFalse( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( end( $variation_ids ) ) );
+
+ $this->assertFalse( Migration::run() );
+
+ foreach ( $variation_ids as $variation_id ) {
+ $this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) );
+ }
+ }
+
+ /**
+ * Create a variation for testing.
+ */
+ private function create_variation(): int {
+ $product = WC_Helper_Product::create_variation_product();
+
+ return (int) $product->get_children()[0];
+ }
+
+ /**
+ * Create a bare variation post for migration batching tests.
+ */
+ private function create_variation_post(): int {
+ return self::factory()->post->create(
+ array(
+ 'post_type' => 'product_variation',
+ 'post_status' => 'publish',
+ )
+ );
+ }
+
+ /**
+ * Create a test attachment.
+ *
+ * @param string $title Attachment title.
+ * @return int
+ */
+ private function create_attachment( string $title ): int {
+ return wp_insert_attachment(
+ array(
+ 'post_title' => $title,
+ 'post_type' => 'attachment',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/VariationGallery/PackageTest.php b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/PackageTest.php
new file mode 100644
index 00000000000..c2d9be64c89
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/PackageTest.php
@@ -0,0 +1,120 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\VariationGallery\Migration;
+use Automattic\WooCommerce\Internal\VariationGallery\Package;
+
+/**
+ * Tests for the variation gallery package bootstrap.
+ */
+class PackageTest extends \WC_Unit_Test_Case {
+
+ /**
+ * Reset migration-related state between tests so action queue and
+ * completion option don't leak across cases.
+ */
+ public function tearDown(): void {
+ WC()->queue()->cancel_all(
+ 'woocommerce_run_update_callback',
+ $this->get_migration_action_args(),
+ 'woocommerce-db-updates'
+ );
+ WC()->queue()->cancel_all(
+ 'woocommerce_run_update_callback',
+ $this->get_unrelated_update_action_args(),
+ 'woocommerce-db-updates'
+ );
+ delete_option( Migration::COMPLETED_OPTION );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox maybe_schedule_migration queues the migration.
+ */
+ public function test_maybe_schedule_migration_queues_the_migration(): void {
+ Package::maybe_schedule_migration();
+
+ $this->assertNotNull(
+ WC()->queue()->get_next(
+ 'woocommerce_run_update_callback',
+ $this->get_migration_action_args(),
+ 'woocommerce-db-updates'
+ )
+ );
+ }
+
+ /**
+ * @testdox maybe_schedule_migration does not duplicate the migration when other DB updates are pending.
+ */
+ public function test_maybe_schedule_migration_does_not_duplicate_existing_migration(): void {
+ WC()->queue()->add(
+ 'woocommerce_run_update_callback',
+ $this->get_unrelated_update_action_args(),
+ 'woocommerce-db-updates'
+ );
+ WC()->queue()->add(
+ 'woocommerce_run_update_callback',
+ $this->get_migration_action_args(),
+ 'woocommerce-db-updates'
+ );
+
+ Package::maybe_schedule_migration();
+
+ $scheduled = WC()->queue()->search(
+ array(
+ 'hook' => 'woocommerce_run_update_callback',
+ 'args' => $this->get_migration_action_args(),
+ 'status' => \ActionScheduler_Store::STATUS_PENDING,
+ 'per_page' => -1,
+ 'group' => 'woocommerce-db-updates',
+ ),
+ 'ids'
+ );
+
+ $this->assertCount( 1, $scheduled );
+ }
+
+ /**
+ * @testdox maybe_schedule_migration does not queue after completion.
+ */
+ public function test_maybe_schedule_migration_does_not_queue_after_completion(): void {
+ update_option( Migration::COMPLETED_OPTION, time() );
+
+ Package::maybe_schedule_migration();
+
+ $this->assertNull(
+ WC()->queue()->get_next(
+ 'woocommerce_run_update_callback',
+ $this->get_migration_action_args(),
+ 'woocommerce-db-updates'
+ )
+ );
+ }
+
+ /**
+ * The action args expected for the migration callback.
+ *
+ * @return array<string, mixed>
+ */
+ private function get_migration_action_args(): array {
+ return array(
+ 'update_callback' => array( Migration::class, 'run' ),
+ );
+ }
+
+ /**
+ * Stand-in action args for an unrelated DB update callback, used to
+ * verify the migration scheduler doesn't confuse other pending actions
+ * for its own.
+ *
+ * @return array<string, mixed>
+ */
+ private function get_unrelated_update_action_args(): array {
+ return array(
+ 'update_callback' => 'some_other_update_callback',
+ );
+ }
+}