2023-08-25 18:42:31 +00:00
|
|
|
/**
|
|
|
|
* External dependencies
|
|
|
|
*/
|
2024-02-07 20:18:41 +00:00
|
|
|
import {
|
|
|
|
store,
|
|
|
|
getContext as getContextFn,
|
|
|
|
getElement,
|
|
|
|
} from '@woocommerce/interactivity';
|
2023-11-21 10:46:15 +00:00
|
|
|
import { StorePart } from '@woocommerce/utils';
|
|
|
|
|
|
|
|
export interface ProductGalleryContext {
|
|
|
|
selectedImage: string;
|
|
|
|
firstMainImageId: string;
|
|
|
|
imageId: string;
|
|
|
|
visibleImagesIds: string[];
|
|
|
|
dialogVisibleImagesIds: string[];
|
|
|
|
isDialogOpen: boolean;
|
|
|
|
productId: string;
|
2024-02-07 20:18:41 +00:00
|
|
|
elementThatTriggeredDialogOpening: HTMLElement | null;
|
2023-08-25 18:42:31 +00:00
|
|
|
}
|
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
const getContext = ( ns?: string ) =>
|
|
|
|
getContextFn< ProductGalleryContext >( ns );
|
|
|
|
|
|
|
|
type Store = typeof productGallery & StorePart< ProductGallery >;
|
2024-02-06 11:55:03 +00:00
|
|
|
const { state, actions } = store< Store >( 'woocommerce/product-gallery' );
|
2023-11-21 10:46:15 +00:00
|
|
|
|
|
|
|
const selectImage = (
|
|
|
|
context: ProductGalleryContext,
|
|
|
|
select: 'next' | 'previous'
|
|
|
|
) => {
|
|
|
|
const imagesIds =
|
|
|
|
context[
|
|
|
|
context.isDialogOpen ? 'dialogVisibleImagesIds' : 'visibleImagesIds'
|
|
|
|
];
|
|
|
|
const selectedImageIdIndex = imagesIds.indexOf( context.selectedImage );
|
|
|
|
const nextImageIndex =
|
|
|
|
select === 'next'
|
|
|
|
? Math.min( selectedImageIdIndex + 1, imagesIds.length - 1 )
|
|
|
|
: Math.max( selectedImageIdIndex - 1, 0 );
|
|
|
|
context.selectedImage = imagesIds[ nextImageIndex ];
|
|
|
|
};
|
|
|
|
|
|
|
|
const closeDialog = ( context: ProductGalleryContext ) => {
|
|
|
|
context.isDialogOpen = false;
|
|
|
|
// Reset the main image.
|
|
|
|
context.selectedImage = context.firstMainImageId;
|
2024-01-12 18:10:33 +00:00
|
|
|
document.body.classList.remove( 'wc-block-product-gallery-modal-open' );
|
2024-02-07 20:18:41 +00:00
|
|
|
|
|
|
|
if ( context.elementThatTriggeredDialogOpening ) {
|
|
|
|
context.elementThatTriggeredDialogOpening?.focus();
|
|
|
|
context.elementThatTriggeredDialogOpening = null;
|
|
|
|
}
|
2023-11-21 10:46:15 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
const productGallery = {
|
|
|
|
state: {
|
|
|
|
get isSelected() {
|
|
|
|
const { selectedImage, imageId } = getContext();
|
|
|
|
return selectedImage === imageId;
|
|
|
|
},
|
|
|
|
get pagerDotFillOpacity(): number {
|
|
|
|
return state.isSelected ? 1 : 0.2;
|
|
|
|
},
|
2024-01-09 09:48:49 +00:00
|
|
|
get pagerButtonPressed(): boolean {
|
|
|
|
return state.isSelected ? true : false;
|
|
|
|
},
|
2024-02-06 11:55:03 +00:00
|
|
|
get thumbnailTabIndex(): string {
|
|
|
|
return state.isSelected ? '0' : '-1';
|
|
|
|
},
|
2023-11-21 10:46:15 +00:00
|
|
|
},
|
|
|
|
actions: {
|
|
|
|
closeDialog: () => {
|
|
|
|
const context = getContext();
|
|
|
|
closeDialog( context );
|
|
|
|
},
|
|
|
|
openDialog: () => {
|
|
|
|
const context = getContext();
|
|
|
|
context.isDialogOpen = true;
|
2024-01-12 18:10:33 +00:00
|
|
|
document.body.classList.add(
|
|
|
|
'wc-block-product-gallery-modal-open'
|
|
|
|
);
|
2024-02-09 16:43:51 +00:00
|
|
|
const dialogPopUp = document.querySelector(
|
|
|
|
'dialog[aria-label="Product gallery"]'
|
2024-02-06 17:28:33 +00:00
|
|
|
);
|
2024-02-09 16:43:51 +00:00
|
|
|
if ( ! dialogPopUp ) {
|
2024-02-06 17:28:33 +00:00
|
|
|
return;
|
|
|
|
}
|
2024-02-09 16:43:51 +00:00
|
|
|
( dialogPopUp as HTMLElement ).focus();
|
2024-02-06 17:28:33 +00:00
|
|
|
|
2024-02-09 16:43:51 +00:00
|
|
|
const dialogPreviousButton = dialogPopUp.querySelectorAll(
|
2024-02-06 17:28:33 +00:00
|
|
|
'.wc-block-product-gallery-large-image-next-previous--button'
|
|
|
|
)[ 0 ];
|
|
|
|
|
|
|
|
if ( ! dialogPreviousButton ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
setTimeout( () => {
|
|
|
|
( dialogPreviousButton as HTMLButtonElement ).focus();
|
|
|
|
}, 100 );
|
2023-11-21 10:46:15 +00:00
|
|
|
},
|
|
|
|
selectImage: () => {
|
|
|
|
const context = getContext();
|
|
|
|
context.selectedImage = context.imageId;
|
|
|
|
},
|
|
|
|
selectNextImage: ( event: MouseEvent ) => {
|
|
|
|
event.stopPropagation();
|
|
|
|
const context = getContext();
|
|
|
|
selectImage( context, 'next' );
|
|
|
|
},
|
|
|
|
selectPreviousImage: ( event: MouseEvent ) => {
|
|
|
|
event.stopPropagation();
|
|
|
|
const context = getContext();
|
|
|
|
selectImage( context, 'previous' );
|
|
|
|
},
|
2024-02-06 11:55:03 +00:00
|
|
|
onThumbnailKeyDown: ( event: KeyboardEvent ) => {
|
|
|
|
const context = getContext();
|
|
|
|
if (
|
|
|
|
event.code === 'Enter' ||
|
|
|
|
event.code === 'Space' ||
|
|
|
|
event.code === 'NumpadEnter'
|
|
|
|
) {
|
|
|
|
if ( event.code === 'Space' ) {
|
|
|
|
event.preventDefault();
|
|
|
|
}
|
|
|
|
context.selectedImage = context.imageId;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
onSelectedLargeImageKeyDown: ( event: KeyboardEvent ) => {
|
|
|
|
if (
|
|
|
|
( state.isSelected && event.code === 'Enter' ) ||
|
|
|
|
event.code === 'Space' ||
|
|
|
|
event.code === 'NumpadEnter'
|
|
|
|
) {
|
|
|
|
if ( event.code === 'Space' ) {
|
|
|
|
event.preventDefault();
|
|
|
|
}
|
|
|
|
actions.openDialog();
|
2024-02-07 20:18:41 +00:00
|
|
|
const largeImageElement = getElement()?.ref as HTMLElement;
|
|
|
|
const context = getContext();
|
|
|
|
context.elementThatTriggeredDialogOpening = largeImageElement;
|
2024-02-06 11:55:03 +00:00
|
|
|
}
|
|
|
|
},
|
|
|
|
onViewAllImagesKeyDown: ( event: KeyboardEvent ) => {
|
|
|
|
if (
|
|
|
|
event.code === 'Enter' ||
|
|
|
|
event.code === 'Space' ||
|
|
|
|
event.code === 'NumpadEnter'
|
|
|
|
) {
|
|
|
|
if ( event.code === 'Space' ) {
|
|
|
|
event.preventDefault();
|
|
|
|
}
|
|
|
|
actions.openDialog();
|
2024-02-07 20:18:41 +00:00
|
|
|
const viewAllImagesElement = getElement()?.ref as HTMLElement;
|
|
|
|
const context = getContext();
|
|
|
|
context.elementThatTriggeredDialogOpening =
|
|
|
|
viewAllImagesElement;
|
2024-02-06 11:55:03 +00:00
|
|
|
}
|
|
|
|
},
|
2023-11-21 10:46:15 +00:00
|
|
|
},
|
|
|
|
callbacks: {
|
|
|
|
watchForChangesOnAddToCartForm: () => {
|
|
|
|
const context = getContext();
|
|
|
|
const variableProductCartForm = document.querySelector(
|
|
|
|
`form[data-product_id="${ context.productId }"]`
|
|
|
|
);
|
|
|
|
|
|
|
|
if ( ! variableProductCartForm ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: Replace with an interactive block that calls `actions.selectImage`.
|
|
|
|
const observer = new MutationObserver( function ( mutations ) {
|
|
|
|
for ( const mutation of mutations ) {
|
|
|
|
const mutationTarget = mutation.target as HTMLElement;
|
|
|
|
const currentImageAttribute =
|
|
|
|
mutationTarget.getAttribute( 'current-image' );
|
|
|
|
if (
|
|
|
|
mutation.type === 'attributes' &&
|
|
|
|
currentImageAttribute &&
|
|
|
|
context.visibleImagesIds.includes(
|
|
|
|
currentImageAttribute
|
|
|
|
)
|
|
|
|
) {
|
|
|
|
context.selectedImage = currentImageAttribute;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} );
|
2023-08-25 18:42:31 +00:00
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
observer.observe( variableProductCartForm, {
|
|
|
|
attributes: true,
|
|
|
|
} );
|
2023-09-12 07:36:44 +00:00
|
|
|
|
2023-12-15 21:22:10 +00:00
|
|
|
const clearVariationsLink = document.querySelector(
|
|
|
|
'.wp-block-add-to-cart-form .reset_variations'
|
|
|
|
);
|
|
|
|
|
|
|
|
const selectFirstImage = () => {
|
|
|
|
context.selectedImage = context.firstMainImageId;
|
|
|
|
};
|
|
|
|
|
|
|
|
if ( clearVariationsLink ) {
|
|
|
|
clearVariationsLink.addEventListener(
|
|
|
|
'click',
|
|
|
|
selectFirstImage
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
return () => {
|
|
|
|
observer.disconnect();
|
2023-12-15 21:22:10 +00:00
|
|
|
document.removeEventListener( 'click', selectFirstImage );
|
2023-11-15 21:05:51 +00:00
|
|
|
};
|
2023-11-21 10:46:15 +00:00
|
|
|
},
|
|
|
|
keyboardAccess: () => {
|
|
|
|
const context = getContext();
|
|
|
|
let allowNavigation = true;
|
2023-10-27 17:12:32 +00:00
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
const handleKeyEvents = ( event: KeyboardEvent ) => {
|
|
|
|
if ( ! allowNavigation || ! context.isDialogOpen ) {
|
2023-10-27 17:12:32 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
// Disable navigation for a brief period to prevent spamming.
|
|
|
|
allowNavigation = false;
|
2023-10-27 17:12:32 +00:00
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
requestAnimationFrame( () => {
|
|
|
|
allowNavigation = true;
|
2023-10-27 17:12:32 +00:00
|
|
|
} );
|
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
// Check if the esc key is pressed.
|
|
|
|
if ( event.code === 'Escape' ) {
|
|
|
|
closeDialog( context );
|
|
|
|
}
|
2023-10-23 14:53:58 +00:00
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
// Check if left arrow key is pressed.
|
|
|
|
if ( event.code === 'ArrowLeft' ) {
|
|
|
|
selectImage( context, 'previous' );
|
|
|
|
}
|
2023-10-23 14:53:58 +00:00
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
// Check if right arrow key is pressed.
|
|
|
|
if ( event.code === 'ArrowRight' ) {
|
|
|
|
selectImage( context, 'next' );
|
|
|
|
}
|
|
|
|
};
|
2023-10-23 14:53:58 +00:00
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
document.addEventListener( 'keydown', handleKeyEvents );
|
2023-10-23 14:53:58 +00:00
|
|
|
|
2023-11-21 10:46:15 +00:00
|
|
|
return () =>
|
|
|
|
document.removeEventListener( 'keydown', handleKeyEvents );
|
2023-08-25 18:42:31 +00:00
|
|
|
},
|
2024-02-09 16:43:51 +00:00
|
|
|
dialogFocusTrap: () => {
|
|
|
|
const dialogPopUp = document.querySelector(
|
|
|
|
'dialog[aria-label="Product gallery"]'
|
|
|
|
) as HTMLElement | null;
|
|
|
|
|
|
|
|
if ( ! dialogPopUp ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const handleKeyEvents = ( event: KeyboardEvent ) => {
|
|
|
|
if ( event.code === 'Tab' ) {
|
|
|
|
const focusableElementsSelectors =
|
|
|
|
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
|
|
|
|
|
|
const focusableElements = dialogPopUp.querySelectorAll(
|
|
|
|
focusableElementsSelectors
|
|
|
|
);
|
|
|
|
|
|
|
|
if ( ! focusableElements.length ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const firstFocusableElement =
|
|
|
|
focusableElements[ 0 ] as HTMLElement;
|
|
|
|
const lastFocusableElement = focusableElements[
|
|
|
|
focusableElements.length - 1
|
|
|
|
] as HTMLElement;
|
|
|
|
|
|
|
|
if (
|
|
|
|
! event.shiftKey &&
|
|
|
|
event.target === lastFocusableElement
|
|
|
|
) {
|
|
|
|
event.preventDefault();
|
|
|
|
firstFocusableElement.focus();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
event.shiftKey &&
|
|
|
|
event.target === firstFocusableElement
|
|
|
|
) {
|
|
|
|
event.preventDefault();
|
|
|
|
lastFocusableElement.focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
dialogPopUp.addEventListener( 'keydown', handleKeyEvents );
|
|
|
|
|
|
|
|
return () =>
|
|
|
|
dialogPopUp.removeEventListener( 'keydown', handleKeyEvents );
|
|
|
|
},
|
2023-08-25 18:42:31 +00:00
|
|
|
},
|
2023-11-21 10:46:15 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
store( 'woocommerce/product-gallery', productGallery );
|
|
|
|
|
|
|
|
export type ProductGallery = typeof productGallery;
|