diff --git a/assets/js/atomic/blocks/product-elements/button/frontend.tsx b/assets/js/atomic/blocks/product-elements/button/frontend.tsx index ebbd2084129..3086aee599e 100644 --- a/assets/js/atomic/blocks/product-elements/button/frontend.tsx +++ b/assets/js/atomic/blocks/product-elements/button/frontend.tsx @@ -1,25 +1,22 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ /** * External dependencies */ +import { store, getContext as getContextFn } from '@woocommerce/interactivity'; +import { select, subscribe, dispatch } from '@wordpress/data'; import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; -import { store as interactivityStore } from '@woocommerce/interactivity'; -import { dispatch, select, subscribe } from '@wordpress/data'; import { Cart } from '@woocommerce/type-defs/cart'; import { createRoot } from '@wordpress/element'; import NoticeBanner from '@woocommerce/base-components/notice-banner'; -type Context = { - woocommerce: { - isLoading: boolean; - addToCartText: string; - productId: number; - displayViewCart: boolean; - quantityToAdd: number; - temporaryNumberOfItems: number; - animationStatus: AnimationStatus; - }; -}; +interface Context { + isLoading: boolean; + addToCartText: string; + productId: number; + displayViewCart: boolean; + quantityToAdd: number; + temporaryNumberOfItems: number; + animationStatus: AnimationStatus; +} enum AnimationStatus { IDLE = 'IDLE', @@ -27,19 +24,26 @@ enum AnimationStatus { SLIDE_IN = 'SLIDE-IN', } -type State = { - woocommerce: { - cart: Cart | undefined; - inTheCartText: string; +interface Store { + state: { + cart?: Cart; + inTheCartText?: string; + numberOfItemsInTheCart: number; + hasCartLoaded: boolean; + slideInAnimation: boolean; + slideOutAnimation: boolean; + addToCartText: string; + displayViewCart: boolean; }; -}; - -type Store = { - state: State; - context: Context; - selectors: any; - ref: HTMLElement; -}; + actions: { + addToCart: () => void; + handleAnimationEnd: ( event: AnimationEvent ) => void; + }; + callbacks: { + startAnimation: () => void; + syncTemporaryNumberOfItemsOnLoad: () => void; + }; +} const storeNoticeClass = '.wc-block-store-notices'; @@ -64,237 +68,174 @@ const injectNotice = ( domNode: Element, errorMessage: string ) => { } ); }; -// RequestIdleCallback is not available in Safari, so we use setTimeout as an alternative. -const callIdleCallback = - window.requestIdleCallback || ( ( cb ) => setTimeout( cb, 100 ) ); - const getProductById = ( cartState: Cart | undefined, productId: number ) => { return cartState?.items.find( ( item ) => item.id === productId ); }; -const getTextButton = ( { - addToCartText, - inTheCartText, - numberOfItems, -}: { - addToCartText: string; - inTheCartText: string; - numberOfItems: number; -} ) => { - if ( numberOfItems === 0 ) { - return addToCartText; - } - return inTheCartText.replace( '###', numberOfItems.toString() ); +const getButtonText = ( + addToCart: string, + inTheCart: string, + numberOfItems: number +): string => { + if ( numberOfItems === 0 ) return addToCart; + return inTheCart.replace( '###', numberOfItems.toString() ); }; -const productButtonSelectors = { - woocommerce: { - addToCartText: ( store: Store ) => { - const { context, state, selectors } = store; +// The `getContextFn` function is wrapped just to avoid prettier issues. +const getContext = ( ns?: string ) => getContextFn< Context >( ns ); +const { state } = store< Store >( 'woocommerce/product-button', { + state: { + get slideInAnimation() { + const { animationStatus } = getContext(); + return animationStatus === AnimationStatus.SLIDE_IN; + }, + get slideOutAnimation() { + const { animationStatus } = getContext(); + return animationStatus === AnimationStatus.SLIDE_OUT; + }, + get numberOfItemsInTheCart() { + const { productId } = getContext(); + const product = getProductById( state.cart, productId ); + return product?.quantity || 0; + }, + get hasCartLoaded(): boolean { + return !! state.cart; + }, + get addToCartText(): string { + const context = getContext(); // We use the temporary number of items when there's no animation, or the // second part of the animation hasn't started. if ( - context.woocommerce.animationStatus === AnimationStatus.IDLE || - context.woocommerce.animationStatus === - AnimationStatus.SLIDE_OUT + context.animationStatus === AnimationStatus.IDLE || + context.animationStatus === AnimationStatus.SLIDE_OUT ) { - return getTextButton( { - addToCartText: context.woocommerce.addToCartText, - inTheCartText: state.woocommerce.inTheCartText, - numberOfItems: context.woocommerce.temporaryNumberOfItems, - } ); + return getButtonText( + context.addToCartText, + state.inTheCartText!, + context.temporaryNumberOfItems + ); } - - return getTextButton( { - addToCartText: context.woocommerce.addToCartText, - inTheCartText: state.woocommerce.inTheCartText, - numberOfItems: - selectors.woocommerce.numberOfItemsInTheCart( store ), - } ); + return getButtonText( + context.addToCartText, + state.inTheCartText!, + state.numberOfItemsInTheCart + ); }, - displayViewCart: ( store: Store ) => { - const { context, selectors } = store; - if ( ! context.woocommerce.displayViewCart ) return false; - if ( ! selectors.woocommerce.hasCartLoaded( store ) ) { - return context.woocommerce.temporaryNumberOfItems > 0; + get displayViewCart(): boolean { + const { displayViewCart, temporaryNumberOfItems } = getContext(); + if ( ! displayViewCart ) return false; + if ( ! state.hasCartLoaded ) { + return temporaryNumberOfItems > 0; } - return selectors.woocommerce.numberOfItemsInTheCart( store ) > 0; - }, - hasCartLoaded: ( { state }: { state: State } ) => { - return state.woocommerce.cart !== undefined; - }, - numberOfItemsInTheCart: ( { state, context }: Store ) => { - const product = getProductById( - state.woocommerce.cart, - context.woocommerce.productId - ); - return product?.quantity || 0; + return state.numberOfItemsInTheCart > 0; }, - slideOutAnimation: ( { context }: Store ) => - context.woocommerce.animationStatus === AnimationStatus.SLIDE_OUT, - slideInAnimation: ( { context }: Store ) => - context.woocommerce.animationStatus === AnimationStatus.SLIDE_IN, }, -}; - -interactivityStore( - // @ts-expect-error: Store function isn't typed. - { - selectors: productButtonSelectors, - actions: { - woocommerce: { - addToCart: async ( store: Store ) => { - const { context, selectors, ref } = store; - - if ( ! ref.classList.contains( 'ajax_add_to_cart' ) ) { - return; - } - - context.woocommerce.isLoading = true; - - // Allow 3rd parties to validate and quit early. - // https://github.com/woocommerce/woocommerce/blob/154dd236499d8a440edf3cde712511b56baa8e45/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js/#L74-L77 - const event = new CustomEvent( - 'should_send_ajax_request.adding_to_cart', - { detail: [ ref ], cancelable: true } - ); - const shouldSendRequest = - document.body.dispatchEvent( event ); - - if ( shouldSendRequest === false ) { - const ajaxNotSentEvent = new CustomEvent( - 'ajax_request_not_sent.adding_to_cart', - { detail: [ false, false, ref ] } - ); - document.body.dispatchEvent( ajaxNotSentEvent ); - return true; - } - - try { - await dispatch( storeKey ).addItemToCart( - context.woocommerce.productId, - context.woocommerce.quantityToAdd - ); - - // After the cart has been updated, sync the temporary number of - // items again. - context.woocommerce.temporaryNumberOfItems = - selectors.woocommerce.numberOfItemsInTheCart( - store - ); - } catch ( error ) { - const storeNoticeBlock = - document.querySelector( storeNoticeClass ); - - if ( ! storeNoticeBlock ) { - document - .querySelector( '.entry-content' ) - ?.prepend( createNoticeContainer() ); - } + actions: { + *addToCart() { + const context = getContext(); + const { productId, quantityToAdd } = context; + + context.isLoading = true; + + try { + yield dispatch( storeKey ).addItemToCart( + productId, + quantityToAdd + ); + + // After the cart is updated, sync the temporary number of items again. + context.temporaryNumberOfItems = state.numberOfItemsInTheCart; + } catch ( error ) { + const storeNoticeBlock = + document.querySelector( storeNoticeClass ); + + if ( ! storeNoticeBlock ) { + document + .querySelector( '.entry-content' ) + ?.prepend( createNoticeContainer() ); + } - const domNode = - storeNoticeBlock ?? - document.querySelector( storeNoticeClass ); + const domNode = + storeNoticeBlock ?? + document.querySelector( storeNoticeClass ); - if ( domNode ) { - injectNotice( domNode, error.message ); - } + if ( domNode ) { + injectNotice( domNode, ( error as Error ).message ); + } - // We don't care about errors blocking execution, but will - // console.error for troubleshooting. - // eslint-disable-next-line no-console - console.error( error ); - } finally { - context.woocommerce.displayViewCart = true; - context.woocommerce.isLoading = false; - } - }, - handleAnimationEnd: ( - store: Store & { event: AnimationEvent } - ) => { - const { event, context, selectors } = store; - if ( event.animationName === 'slideOut' ) { - // When the first part of the animation (slide-out) ends, we move - // to the second part (slide-in). - context.woocommerce.animationStatus = - AnimationStatus.SLIDE_IN; - } else if ( event.animationName === 'slideIn' ) { - // When the second part of the animation ends, we update the - // temporary number of items to sync it with the cart and reset the - // animation status so it can be triggered again. - context.woocommerce.temporaryNumberOfItems = - selectors.woocommerce.numberOfItemsInTheCart( - store - ); - context.woocommerce.animationStatus = - AnimationStatus.IDLE; - } - }, - }, + // We don't care about errors blocking execution, but will + // console.error for troubleshooting. + // eslint-disable-next-line no-console + console.error( error ); + } finally { + context.displayViewCart = true; + context.isLoading = false; + } }, - init: { - woocommerce: { - syncTemporaryNumberOfItemsOnLoad: ( store: Store ) => { - const { selectors, context } = store; - // If the cart has loaded when we instantiate this element, we sync - // the temporary number of items with the number of items in the cart - // to avoid triggering the animation. We do this only once, but we - // use useLayoutEffect to avoid the useEffect flickering. - if ( selectors.woocommerce.hasCartLoaded( store ) ) { - context.woocommerce.temporaryNumberOfItems = - selectors.woocommerce.numberOfItemsInTheCart( - store - ); - } - }, - }, + handleAnimationEnd: ( event: AnimationEvent ) => { + const context = getContext(); + if ( event.animationName === 'slideOut' ) { + // When the first part of the animation (slide-out) ends, we move + // to the second part (slide-in). + context.animationStatus = AnimationStatus.SLIDE_IN; + } else if ( event.animationName === 'slideIn' ) { + // When the second part of the animation ends, we update the + // temporary number of items to sync it with the cart and reset the + // animation status so it can be triggered again. + context.temporaryNumberOfItems = state.numberOfItemsInTheCart; + context.animationStatus = AnimationStatus.IDLE; + } }, - effects: { - woocommerce: { - startAnimation: ( store: Store ) => { - const { context, selectors } = store; - // We start the animation if the cart has loaded, the temporary number - // of items is out of sync with the number of items in the cart, the - // button is not loading (because that means the user started the - // interaction) and the animation hasn't started yet. - if ( - selectors.woocommerce.hasCartLoaded( store ) && - context.woocommerce.temporaryNumberOfItems !== - selectors.woocommerce.numberOfItemsInTheCart( - store - ) && - ! context.woocommerce.isLoading && - context.woocommerce.animationStatus === - AnimationStatus.IDLE - ) { - context.woocommerce.animationStatus = - AnimationStatus.SLIDE_OUT; - } - }, - }, + }, + callbacks: { + syncTemporaryNumberOfItemsOnLoad: () => { + const context = getContext(); + // If the cart has loaded when we instantiate this element, we sync + // the temporary number of items with the number of items in the cart + // to avoid triggering the animation. We do this only once, but we + // use useLayoutEffect to avoid the useEffect flickering. + if ( state.hasCartLoaded ) { + context.temporaryNumberOfItems = state.numberOfItemsInTheCart; + } + }, + startAnimation: () => { + const context = getContext(); + // We start the animation if the cart has loaded, the temporary number + // of items is out of sync with the number of items in the cart, the + // button is not loading (because that means the user started the + // interaction) and the animation hasn't started yet. + if ( + state.hasCartLoaded && + context.temporaryNumberOfItems !== + state.numberOfItemsInTheCart && + ! context.isLoading && + context.animationStatus === AnimationStatus.IDLE + ) { + context.animationStatus = AnimationStatus.SLIDE_OUT; + } }, }, - { - afterLoad: ( store: Store ) => { - const { state, selectors } = store; - // Subscribe to changes in Cart data. - subscribe( () => { - const cartData = select( storeKey ).getCartData(); - const isResolutionFinished = - select( storeKey ).hasFinishedResolution( 'getCartData' ); - if ( isResolutionFinished ) { - state.woocommerce.cart = cartData; - } - }, storeKey ); +} ); + +// Subscribe to changes in Cart data. +subscribe( () => { + const cartData = select( storeKey ).getCartData(); + const isResolutionFinished = + select( storeKey ).hasFinishedResolution( 'getCartData' ); + if ( isResolutionFinished ) { + state.cart = cartData; + } +}, storeKey ); - // This selector triggers a fetch of the Cart data. It is done in a - // `requestIdleCallback` to avoid potential performance issues. - callIdleCallback( () => { - if ( ! selectors.woocommerce.hasCartLoaded( store ) ) { - select( storeKey ).getCartData(); - } - } ); - }, +// RequestIdleCallback is not available in Safari, so we use setTimeout as an alternative. +const callIdleCallback = + window.requestIdleCallback || ( ( cb ) => setTimeout( cb, 100 ) ); + +// This selector triggers a fetch of the Cart data. It is done in a +// `requestIdleCallback` to avoid potential performance issues. +callIdleCallback( () => { + if ( ! state.hasCartLoaded ) { + select( storeKey ).getCartData(); } -); +} ); diff --git a/assets/js/blocks/product-gallery/frontend.tsx b/assets/js/blocks/product-gallery/frontend.tsx index eae6d9c0663..23dcd388573 100644 --- a/assets/js/blocks/product-gallery/frontend.tsx +++ b/assets/js/blocks/product-gallery/frontend.tsx @@ -1,221 +1,151 @@ /** * External dependencies */ -import { store as interactivityApiStore } from '@woocommerce/interactivity'; - -interface State { - [ key: string ]: unknown; -} - -export interface ProductGalleryInteractivityApiContext { - woocommerce: { - selectedImage: string; - imageId: string; - visibleImagesIds: string[]; - dialogVisibleImagesIds: string[]; - isDialogOpen: boolean; - productId: string; - }; -} - -export interface ProductGallerySelectors { - woocommerce: { - isSelected: ( store: unknown ) => boolean; - pagerDotFillOpacity: ( store: SelectorsStore ) => number; - selectedImageIndex: ( store: SelectorsStore ) => number; - isDialogOpen: ( store: unknown ) => boolean; - }; -} - -interface Actions { - woocommerce: { - thumbnails: { - handleClick: ( - context: ProductGalleryInteractivityApiContext - ) => void; - }; - handlePreviousImageButtonClick: { - ( store: Store ): void; - }; - handleNextImageButtonClick: { - ( store: Store ): void; - }; - }; -} - -interface Store { - state: State; - context: ProductGalleryInteractivityApiContext; - selectors: ProductGallerySelectors; - actions: Actions; - ref?: HTMLElement; +import { store, getContext as getContextFn } from '@woocommerce/interactivity'; +import { StorePart } from '@woocommerce/utils'; + +export interface ProductGalleryContext { + selectedImage: string; + imageId: string; + visibleImagesIds: string[]; + dialogVisibleImagesIds: string[]; + isDialogOpen: boolean; + productId: string; } -interface Event { - keyCode: number; -} - -type SelectorsStore = Pick< Store, 'context' | 'selectors' | 'ref' >; +const getContext = ( ns?: string ) => + getContextFn< ProductGalleryContext >( ns ); + +type Store = typeof productGallery & StorePart< ProductGallery >; +const { state } = store< Store >( 'woocommerce/product-gallery' ); + +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 productGallery = { + state: { + get isSelected() { + const { selectedImage, imageId } = getContext(); + return selectedImage === imageId; + }, + get pagerDotFillOpacity(): number { + return state.isSelected ? 1 : 0.2; + }, + }, + actions: { + closeDialog: () => { + const context = getContext(); + context.isDialogOpen = false; + }, + openDialog: () => { + const context = getContext(); + context.isDialogOpen = true; + }, + 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' ); + }, + }, + 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; + } + } + } ); -enum Keys { - ESC = 27, - LEFT_ARROW = 37, - RIGHT_ARROW = 39, -} + observer.observe( variableProductCartForm, { + attributes: true, + } ); -interactivityApiStore( { - state: {}, - effects: { - woocommerce: { - watchForChangesOnAddToCartForm: ( store: Store ) => { - const variableProductCartForm = document.querySelector( - `form[data-product_id="${ store.context.woocommerce.productId }"]` - ); + return () => { + observer.disconnect(); + }; + }, + keyboardAccess: () => { + const context = getContext(); + let allowNavigation = true; - if ( ! variableProductCartForm ) { + const handleKeyEvents = ( event: KeyboardEvent ) => { + if ( ! allowNavigation || ! context.isDialogOpen ) { return; } - 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 && - store.context.woocommerce.visibleImagesIds.includes( - currentImageAttribute - ) - ) { - store.context.woocommerce.selectedImage = - currentImageAttribute; - } - } - } ); + // Disable navigation for a brief period to prevent spamming. + allowNavigation = false; - observer.observe( variableProductCartForm, { - attributes: true, + requestAnimationFrame( () => { + allowNavigation = true; } ); - return () => { - observer.disconnect(); - }; - }, - keyboardAccess: ( store: Store ) => { - const { context, actions } = store; - let allowNavigation = true; - - const handleKeyEvents = ( event: Event ) => { - if ( - ! allowNavigation || - ! context.woocommerce?.isDialogOpen - ) { - return; - } - - // Disable navigation for a brief period to prevent spamming. - allowNavigation = false; - - requestAnimationFrame( () => { - allowNavigation = true; - } ); + // Check if the esc key is pressed. + if ( event.code === 'Escape' ) { + context.isDialogOpen = false; + } - // Check if the esc key is pressed. - if ( event.keyCode === Keys.ESC ) { - context.woocommerce.isDialogOpen = false; - } + // Check if left arrow key is pressed. + if ( event.code === 'ArrowLeft' ) { + selectImage( context, 'previous' ); + } - // Check if left arrow key is pressed. - if ( event.keyCode === Keys.LEFT_ARROW ) { - actions.woocommerce.handlePreviousImageButtonClick( - store - ); - } + // Check if right arrow key is pressed. + if ( event.code === 'ArrowRight' ) { + selectImage( context, 'next' ); + } + }; - // Check if right arrow key is pressed. - if ( event.keyCode === Keys.RIGHT_ARROW ) { - actions.woocommerce.handleNextImageButtonClick( store ); - } - }; + document.addEventListener( 'keydown', handleKeyEvents ); - document.addEventListener( 'keydown', handleKeyEvents ); - }, - }, - }, - selectors: { - woocommerce: { - isSelected: ( { context }: Store ) => { - return ( - context?.woocommerce.selectedImage === - context?.woocommerce.imageId - ); - }, - pagerDotFillOpacity( store: SelectorsStore ) { - const { context } = store; - - return context?.woocommerce.selectedImage === - context?.woocommerce.imageId - ? 1 - : 0.2; - }, - isDialogOpen: ( { context }: Store ) => { - return context.woocommerce.isDialogOpen; - }, - }, - }, - actions: { - woocommerce: { - thumbnails: { - handleClick: ( { context }: Store ) => { - context.woocommerce.selectedImage = - context.woocommerce.imageId; - }, - }, - dialog: { - handleCloseButtonClick: ( { context }: Store ) => { - context.woocommerce.isDialogOpen = false; - }, - }, - handleSelectImage: ( { context }: Store ) => { - context.woocommerce.selectedImage = context.woocommerce.imageId; - }, - handleNextImageButtonClick: ( store: Store ) => { - const { context } = store; - const imagesIds = - context.woocommerce[ - context.woocommerce.isDialogOpen - ? 'dialogVisibleImagesIds' - : 'visibleImagesIds' - ]; - const selectedImageIdIndex = imagesIds.indexOf( - context.woocommerce.selectedImage - ); - const nextImageIndex = Math.min( - selectedImageIdIndex + 1, - imagesIds.length - 1 - ); - - context.woocommerce.selectedImage = imagesIds[ nextImageIndex ]; - }, - handlePreviousImageButtonClick: ( store: Store ) => { - const { context } = store; - const imagesIds = - context.woocommerce[ - context.woocommerce.isDialogOpen - ? 'dialogVisibleImagesIds' - : 'visibleImagesIds' - ]; - const selectedImageIdIndex = imagesIds.indexOf( - context.woocommerce.selectedImage - ); - const previousImageIndex = Math.max( - selectedImageIdIndex - 1, - 0 - ); - context.woocommerce.selectedImage = - imagesIds[ previousImageIndex ]; - }, + return () => + document.removeEventListener( 'keydown', handleKeyEvents ); }, }, -} ); +}; + +store( 'woocommerce/product-gallery', productGallery ); + +export type ProductGallery = typeof productGallery; diff --git a/assets/js/blocks/product-gallery/inner-blocks/product-gallery-large-image/frontend.tsx b/assets/js/blocks/product-gallery/inner-blocks/product-gallery-large-image/frontend.tsx index 74a274203b0..d32513ec1d4 100644 --- a/assets/js/blocks/product-gallery/inner-blocks/product-gallery-large-image/frontend.tsx +++ b/assets/js/blocks/product-gallery/inner-blocks/product-gallery-large-image/frontend.tsx @@ -1,173 +1,121 @@ /** * External dependencies */ -import { store as interactivityStore } from '@woocommerce/interactivity'; +import { + store, + getContext as getContextFn, + getElement, +} from '@woocommerce/interactivity'; +import { StorePart } from '@woocommerce/utils'; /** * Internal dependencies */ -import { - ProductGalleryInteractivityApiContext, - ProductGallerySelectors, -} from '../../frontend'; +import type { ProductGalleryContext, ProductGallery } from '../../frontend'; type Context = { - woocommerce: { - styles: - | { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'transform-origin': string; - transform: string; - transition: string; - } - | undefined; - isDialogOpen: boolean; + styles: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'transform-origin': string; + transform: string; + transition: string; }; -} & ProductGalleryInteractivityApiContext; +} & ProductGalleryContext; -type Store = { - context: Context; - selectors: typeof productGalleryLargeImageSelectors & - ProductGallerySelectors; - ref: HTMLElement; -}; +const getContext = ( ns?: string ) => getContextFn< Context >( ns ); -const productGalleryLargeImageSelectors = { - woocommerce: { - productGalleryLargeImage: { - styles: ( { context }: Store ) => { - const { styles } = context.woocommerce; - - return Object.entries( styles ?? [] ).reduce( - ( acc, [ key, value ] ) => { - const style = `${ key }:${ value };`; - return acc.length > 0 ? `${ acc } ${ style }` : style; - }, - '' - ); - }, - }, - }, -}; +type Store = typeof productGalleryLargeImage & StorePart< ProductGallery >; +const { state, actions } = store< Store >( 'woocommerce/product-gallery' ); let isDialogStatusChanged = false; -const resetImageZoom = ( context: Context ) => { - if ( context.woocommerce.styles ) { - context.woocommerce.styles.transform = `scale(1.0)`; - context.woocommerce.styles[ 'transform-origin' ] = ''; - } -}; - -interactivityStore( - // @ts-expect-error: Store function isn't typed. - { - selectors: productGalleryLargeImageSelectors, - actions: { - woocommerce: { - handleMouseMove: ( { - event, - context, - }: { - event: MouseEvent; - context: Context; - } ) => { - const target = event.target as HTMLElement; - const isMouseEventFromLargeImage = - target.classList.contains( - 'wc-block-woocommerce-product-gallery-large-image__image' - ); - if ( ! isMouseEventFromLargeImage ) { - resetImageZoom( context ); - return; - } +const productGalleryLargeImage = { + state: { + get styles() { + const { styles } = getContext(); + return Object.entries( styles ?? [] ).reduce( + ( acc, [ key, value ] ) => { + const style = `${ key }:${ value };`; + return acc.length > 0 ? `${ acc } ${ style }` : style; + }, + '' + ); + }, + }, + actions: { + startZoom: ( event: MouseEvent ) => { + const target = event.target as HTMLElement; + const isMouseEventFromLargeImage = target.classList.contains( + 'wc-block-woocommerce-product-gallery-large-image__image' + ); + if ( ! isMouseEventFromLargeImage ) { + return actions.resetZoom(); + } - const element = event.target as HTMLElement; - const percentageX = - ( event.offsetX / element.clientWidth ) * 100; - const percentageY = - ( event.offsetY / element.clientHeight ) * 100; + const element = event.target as HTMLElement; + const percentageX = ( event.offsetX / element.clientWidth ) * 100; + const percentageY = ( event.offsetY / element.clientHeight ) * 100; - if ( context.woocommerce.styles ) { - context.woocommerce.styles.transform = `scale(1.3)`; + const { styles } = getContext(); - context.woocommerce.styles[ - 'transform-origin' - ] = `${ percentageX }% ${ percentageY }%`; - } - }, - handleMouseLeave: ( { context }: { context: Context } ) => { - resetImageZoom( context ); - }, - handleClick: ( { - context, - event, - }: { - context: Context; - event: Event; - } ) => { - if ( - ( event.target as HTMLElement ).classList.contains( - 'wc-block-product-gallery-dialog-on-click' - ) - ) { - context.woocommerce.isDialogOpen = true; - } - }, - }, + if ( styles ) { + styles.transform = `scale(1.3)`; + styles[ + 'transform-origin' + ] = `${ percentageX }% ${ percentageY }%`; + } + }, + resetZoom: () => { + const context = getContext(); + if ( context.styles ) { + context.styles.transform = `scale(1.0)`; + context.styles[ 'transform-origin' ] = ''; + } }, - effects: { - woocommerce: { - scrollInto: ( store: Store ) => { - if ( ! store.selectors.woocommerce.isSelected( store ) ) { - return; - } + }, + callbacks: { + scrollInto: () => { + if ( ! state.isSelected ) { + return; + } - // Scroll to the selected image with a smooth animation. - if ( - store.context.woocommerce.isDialogOpen === - isDialogStatusChanged - ) { - store.ref.scrollIntoView( { - behavior: 'smooth', - block: 'nearest', - inline: 'center', - } ); - } + const { isDialogOpen } = getContext(); + const { ref } = getElement(); + // Scroll to the selected image with a smooth animation. + if ( isDialogOpen === isDialogStatusChanged ) { + ref.scrollIntoView( { + behavior: 'smooth', + block: 'nearest', + inline: 'center', + } ); + } - // Scroll to the selected image when the dialog is being opened without an animation. - if ( - store.context.woocommerce.isDialogOpen && - store.context.woocommerce.isDialogOpen !== - isDialogStatusChanged && - store.ref.closest( 'dialog' ) - ) { - store.ref.scrollIntoView( { - behavior: 'instant', - block: 'nearest', - inline: 'center', - } ); + // Scroll to the selected image when the dialog is being opened without an animation. + if ( + isDialogOpen && + isDialogOpen !== isDialogStatusChanged && + ref.closest( 'dialog' ) + ) { + ref.scrollIntoView( { + behavior: 'instant', + block: 'nearest', + inline: 'center', + } ); - isDialogStatusChanged = - store.context.woocommerce.isDialogOpen; - } + isDialogStatusChanged = isDialogOpen; + } - // Scroll to the selected image when the dialog is being closed without an animation. - if ( - ! store.context.woocommerce.isDialogOpen && - store.context.woocommerce.isDialogOpen !== - isDialogStatusChanged - ) { - store.ref.scrollIntoView( { - behavior: 'instant', - block: 'nearest', - inline: 'center', - } ); - isDialogStatusChanged = - store.context.woocommerce.isDialogOpen; - } - }, - }, + // Scroll to the selected image when the dialog is being closed without an animation. + if ( ! isDialogOpen && isDialogOpen !== isDialogStatusChanged ) { + ref.scrollIntoView( { + behavior: 'instant', + block: 'nearest', + inline: 'center', + } ); + isDialogStatusChanged = isDialogOpen; + } }, - } -); + }, +}; + +store< Store >( 'woocommerce/product-gallery', productGalleryLargeImage ); diff --git a/assets/js/utils/index.ts b/assets/js/utils/index.ts index 7a6f4094a00..65178db2358 100644 --- a/assets/js/utils/index.ts +++ b/assets/js/utils/index.ts @@ -11,3 +11,4 @@ export * from './is-site-editor-page'; export * from './is-widget-editor-page'; export * from './trim-words'; export * from './find-block'; +export * from './interactivity'; diff --git a/assets/js/utils/interactivity.ts b/assets/js/utils/interactivity.ts new file mode 100644 index 00000000000..9e8ec2225e8 --- /dev/null +++ b/assets/js/utils/interactivity.ts @@ -0,0 +1,7 @@ +// Util to add the type of another store part. +// eslint-disable-next-line @typescript-eslint/ban-types +export type StorePart< T > = T extends Function + ? T + : T extends object + ? { [ P in keyof T ]?: StorePart< T[ P ] > } + : T; diff --git a/bin/webpack-configs.js b/bin/webpack-configs.js index 0f0da5277c6..58e735fdf55 100644 --- a/bin/webpack-configs.js +++ b/bin/webpack-configs.js @@ -35,9 +35,12 @@ const isProduction = NODE_ENV === 'production'; * Shared config for all script builds. */ let initialBundleAnalyzerPort = 8888; -const getSharedPlugins = ( { bundleAnalyzerReportTitle } ) => +const getSharedPlugins = ( { + bundleAnalyzerReportTitle, + checkCircularDeps = true, +} ) => [ - CHECK_CIRCULAR_DEPS === 'true' + CHECK_CIRCULAR_DEPS === 'true' && checkCircularDeps !== false ? new CircularDependencyPlugin( { exclude: /node_modules/, cwd: process.cwd(), @@ -924,6 +927,7 @@ const getInteractivityAPIConfig = ( options = {} ) => { plugins: [ ...getSharedPlugins( { bundleAnalyzerReportTitle: 'WP directives', + checkCircularDeps: false, } ), new ProgressBarPlugin( getProgressBarPluginConfig( 'WP directives' ) diff --git a/src/BlockTypes/ProductButton.php b/src/BlockTypes/ProductButton.php index 353b722ab49..f3a84d75a24 100644 --- a/src/BlockTypes/ProductButton.php +++ b/src/BlockTypes/ProductButton.php @@ -108,37 +108,33 @@ protected function render( $attributes, $content, $block ) { ) ); - wc_store( + wc_initial_state( + 'woocommerce/product-button', array( - 'state' => array( - 'woocommerce' => array( - 'inTheCartText' => sprintf( - /* translators: %s: product number. */ - __( '%s in cart', 'woo-gutenberg-products-block' ), - '###' - ), - ), + 'inTheCartText' => sprintf( + /* translators: %s: product number. */ + __( '%s in cart', 'woo-gutenberg-products-block' ), + '###' ), ) ); $default_quantity = 1; + /** + * Filters the change the quantity to add to cart. + * + * @since 10.9.0 + * @param number $default_quantity The default quantity. + * @param number $product_id The product id. + */ + $quantity_to_add = apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id() ); $context = array( - 'woocommerce' => array( - /** - * Filters the change the quantity to add to cart. - * - * @since 10.9.0 - * @param number $default_quantity The default quantity. - * @param number $product_id The product id. - */ - 'quantityToAdd' => apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id() ), - 'productId' => $product->get_id(), - 'addToCartText' => null !== $product->add_to_cart_text() ? $product->add_to_cart_text() : __( 'Add to cart', 'woo-gutenberg-products-block' ), - 'temporaryNumberOfItems' => $number_of_items_in_cart, - 'animationStatus' => 'IDLE', - ), + 'quantityToAdd' => $quantity_to_add, + 'productId' => $product->get_id(), + 'addToCartText' => null !== $product->add_to_cart_text() ? $product->add_to_cart_text() : __( 'Add to cart', 'woo-gutenberg-products-block' ), + 'temporaryNumberOfItems' => $number_of_items_in_cart, + 'animationStatus' => 'IDLE', ); /** @@ -168,20 +164,27 @@ protected function render( $attributes, $content, $block ) { $this->prevent_cache(); } - $div_directives = 'data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK ) . '\''; + $interactive = array( + 'namespace' => 'woocommerce/product-button', + ); + + $div_directives = ' + data-wc-interactive=\'' . wp_json_encode( $interactive, JSON_NUMERIC_CHECK ) . '\' + data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK ) . '\' + '; $button_directives = ' - data-wc-on--click="actions.woocommerce.addToCart" - data-wc-class--loading="context.woocommerce.isLoading" + data-wc-on--click="actions.addToCart" + data-wc-class--loading="context.isLoading" '; $span_button_directives = ' - data-wc-text="selectors.woocommerce.addToCartText" - data-wc-class--wc-block-slide-in="selectors.woocommerce.slideInAnimation" - data-wc-class--wc-block-slide-out="selectors.woocommerce.slideOutAnimation" - data-wc-layout-init="init.woocommerce.syncTemporaryNumberOfItemsOnLoad" - data-wc-effect="effects.woocommerce.startAnimation" - data-wc-on--animationend="actions.woocommerce.handleAnimationEnd" + data-wc-text="state.addToCartText" + data-wc-class--wc-block-slide-in="state.slideInAnimation" + data-wc-class--wc-block-slide-out="state.slideOutAnimation" + data-wc-on--animationend="actions.handleAnimationEnd" + data-wc-watch="callbacks.startAnimation" + data-wc-layout-init="callbacks.syncTemporaryNumberOfItemsOnLoad" '; /** @@ -194,20 +197,20 @@ protected function render( $attributes, $content, $block ) { return apply_filters( 'woocommerce_loop_add_to_cart_link', strtr( - '
- <{html_element} - href="{add_to_cart_url}" - class="{button_classes}" - style="{button_styles}" - {attributes} - {button_directives} - > - {add_to_cart_text} - - {view_cart_html} -
', + <{html_element} + href="{add_to_cart_url}" + class="{button_classes}" + style="{button_styles}" + {attributes} + {button_directives} + > + {add_to_cart_text} + + {view_cart_html} + ', array( '{classes}' => esc_attr( $text_align_styles_and_classes['class'] ?? '' ), '{custom_classes}' => esc_attr( $classname . ' ' . $custom_width_classes . ' ' . $custom_align_classes ), @@ -259,7 +262,10 @@ private function prevent_cache() { */ private function get_view_cart_html() { return sprintf( - '