Commit 31d2d2cf078 for woocommerce
commit 31d2d2cf078975f5c3aad3122c03965e7c4e57db
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Mon Jul 27 12:59:28 2026 +0300
Keep product image help tip aligned after image changes (#66659)
* Fix product image tooltip alignment
* Add product image tooltip changelog
* test(e2e): assert product image help tip adjacency instead of pixel offset
The alignment check compared bounding-box y coordinates with a 4px
budget, but the Set product image state renders only ~1px inside that
budget on macOS. CI runs Linux with a different font stack, so the
assertion risked intermittent failures that would not reproduce locally.
Assert the DOM relationship the fix actually establishes instead: the
tip is inserted immediately after the active action, so the adjacent
sibling selector verifies placement without depending on font metrics.
Refs #36143
* docs(product): explain product image observer configuration
Other admin observers in the codebase pass subtree: true, so childList
alone can read as an omission. Document that WPSetThumbnailHTML()
replaces the direct children of .inside, making childList sufficient,
and that skipping subtree keeps the observer blind to the tooltip's own
insertion one level deeper.
Refs #36143
---------
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-36143-product-image-tooltip b/plugins/woocommerce/changelog/fix-36143-product-image-tooltip
new file mode 100644
index 00000000000..eb31d81a806
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-36143-product-image-tooltip
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the product image help tip aligned when setting or removing an image.
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
index 4759e9c9288..103e971788d 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
@@ -1570,8 +1570,7 @@ jQuery( function ( $ ) {
keepAlive: true,
} );
- // add a tooltip to the right of the product image meta box "Set product image" and "Add product gallery images"
- const setProductImageLink = $( '#set-post-thumbnail' );
+ // Add tooltips to the product image and product gallery actions.
// Escape the translated label before interpolating into the attribute so a
// translation containing quotes or markup cannot break the rendered span.
const tooltipMarkup = `<span class="woocommerce-help-tip" tabindex="0" aria-label="${ _.escape(
@@ -1585,16 +1584,53 @@ jQuery( function ( $ ) {
delay: 200,
keepAlive: true,
};
+ const productImageContainer = $( '#postimagediv .inside' );
+ const productImageTooltipClass = 'woocommerce-product-image-help-tip';
+
+ const syncProductImageTooltip = () => {
+ const removeProductImageLink = $( '#remove-post-thumbnail' );
+ const productImageLink = removeProductImageLink.length
+ ? removeProductImageLink
+ : $( '#set-post-thumbnail' );
+ const existingTooltip = productImageContainer.find(
+ `.${ productImageTooltipClass }`
+ );
+
+ if ( ! productImageLink.length ) {
+ existingTooltip.remove();
+ return;
+ }
- if ( setProductImageLink ) {
+ if (
+ existingTooltip.length &&
+ existingTooltip.prev()[ 0 ] === productImageLink[ 0 ]
+ ) {
+ return;
+ }
+
+ existingTooltip.remove();
$( tooltipMarkup )
- .insertAfter( setProductImageLink )
+ .addClass( productImageTooltipClass )
+ .insertAfter( productImageLink )
.tipTip( tooltipData );
+ };
+
+ if ( productImageContainer.length ) {
+ syncProductImageTooltip();
+
+ // WPSetThumbnailHTML() replaces the direct children of `.inside`, so
+ // `childList` alone observes every image change. Omitting `subtree`
+ // also keeps the observer blind to the tooltip's own insertion, which
+ // happens one level deeper.
+ new MutationObserver( syncProductImageTooltip ).observe(
+ productImageContainer[ 0 ],
+ { childList: true }
+ );
}
const addProductImagesLink = $( '.add_product_images > a' );
- if ( addProductImagesLink ) {
+ if ( addProductImagesLink.length ) {
$( tooltipMarkup )
.insertAfter( addProductImagesLink )
.tipTip( tooltipData );
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-images.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-images.spec.ts
index 3124315c58d..e2de803de23 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-images.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-images.spec.ts
@@ -31,6 +31,34 @@ async function addImageFromLibrary(
return dataId;
}
+async function expectProductImageHelpTipNextTo(
+ page: Page,
+ actionName: 'Remove product image' | 'Set product image'
+) {
+ const productImageBox = page.locator( '#postimagediv' );
+ const action = productImageBox
+ .getByRole( 'link', { name: actionName } )
+ .or( productImageBox.getByRole( 'button', { name: actionName } ) );
+ const actionId =
+ actionName === 'Remove product image'
+ ? 'remove-post-thumbnail'
+ : 'set-post-thumbnail';
+ // The fix inserts the tip immediately after the active action, so assert
+ // that DOM adjacency directly instead of comparing rendered coordinates,
+ // which vary with the platform font stack.
+ const helpTip = productImageBox.locator(
+ `#${ actionId } + .woocommerce-product-image-help-tip`
+ );
+
+ await expect( action ).toBeVisible();
+ await expect( helpTip ).toBeVisible();
+ await expect( helpTip ).toHaveAttribute( 'tabindex', '0' );
+ await expect( helpTip ).toHaveAttribute(
+ 'aria-label',
+ /For best results, upload JPEG or PNG files/
+ );
+}
+
const test = baseTest.extend( {
storageState: ADMIN_STATE_PATH,
product: async ( { restApi }, use ) => {
@@ -88,6 +116,37 @@ const test = baseTest.extend( {
} );
test.describe( 'Products > Product Images', () => {
+ test( 'keeps the product image help tip aligned when the image changes', async ( {
+ page,
+ productWithImage,
+ } ) => {
+ await page.goto(
+ `wp-admin/post.php?post=${ productWithImage.id }&action=edit`
+ );
+
+ await expectProductImageHelpTipNextTo( page, 'Remove product image' );
+
+ await page
+ .getByRole( 'link', { name: 'Remove product image' } )
+ .or(
+ page.getByRole( 'button', {
+ name: 'Remove product image',
+ } )
+ )
+ .click();
+ await expectProductImageHelpTipNextTo( page, 'Set product image' );
+
+ await page
+ .getByRole( 'link', { name: 'Set product image' } )
+ .or( page.getByRole( 'button', { name: 'Set product image' } ) )
+ .click();
+ await addImageFromLibrary( page, 'image-02', 'Set product image' );
+ await expectProductImageHelpTipNextTo( page, 'Remove product image' );
+ await expect(
+ page.locator( '.woocommerce-product-image-help-tip' )
+ ).toHaveCount( 1 );
+ } );
+
test( 'can set product image', async ( { page, product } ) => {
await test.step( 'Navigate to product edit page', async () => {
await page.goto(