/** * External dependencies */ import { store, navigate, prefetch, getElement, getContext, } from '@woocommerce/interactivity'; import { triggerProductListRenderedEvent, triggerViewedProductEvent, } from '@woocommerce/base-utils'; /** * Internal dependencies */ import { CoreCollectionNames } from './types'; import './style.scss'; export type ProductCollectionStoreContext = { // Available on the
  • product element and deeper productId?: number; isPrefetchNextOrPreviousLink: boolean; animation: 'start' | 'finish'; accessibilityMessage: string; accessibilityLoadingMessage: string; accessibilityLoadedMessage: string; collection: CoreCollectionNames; }; const isValidLink = ( ref: HTMLAnchorElement ) => ref && ref instanceof window.HTMLAnchorElement && ref.href && ( ! ref.target || ref.target === '_self' ) && ref.origin === window.location.origin; const isValidEvent = ( event: MouseEvent ) => event.button === 0 && // Left clicks only. ! event.metaKey && // Open in new tab (Mac). ! event.ctrlKey && // Open in new tab (Windows). ! event.altKey && // Download. ! event.shiftKey && ! event.defaultPrevented; const forcePageReload = ( href: string ) => { window.location.assign( href ); // It's function called in generator expecting asyncFunc return. // eslint-disable-next-line @typescript-eslint/no-empty-function return new Promise( () => {} ); }; /** * Ensures the visibility of the first product in the collection. * Scrolls the page to the first product if it's not in the viewport. * * @param {string} wcNavigationId Unique ID for each Product Collection block on page/post. */ function scrollToFirstProductIfNotVisible( wcNavigationId?: string ) { if ( ! wcNavigationId ) { return; } const productSelector = `[data-wc-navigation-id=${ wcNavigationId }] .wc-block-product-template .wc-block-product`; const product = document.querySelector( productSelector ); if ( product ) { const rect = product.getBoundingClientRect(); const isVisible = rect.top >= 0 && rect.left >= 0 && rect.bottom <= ( window.innerHeight || document.documentElement.clientHeight ) && rect.right <= ( window.innerWidth || document.documentElement.clientWidth ); // If the product is not visible, scroll to it. if ( ! isVisible ) { product.scrollIntoView( { behavior: 'smooth', block: 'start', } ); } } } const productCollectionStore = { state: { get startAnimation() { return ( getContext< ProductCollectionStoreContext >().animation === 'start' ); }, get finishAnimation() { return ( getContext< ProductCollectionStoreContext >().animation === 'finish' ); }, }, actions: { *navigate( event: MouseEvent ) { const ctx = getContext< ProductCollectionStoreContext >(); const { ref } = getElement(); const wcNavigationId = ( ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement )?.dataset?.wcNavigationId; const isDisabled = ( ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement )?.dataset.wcNavigationDisabled; if ( isDisabled ) { yield forcePageReload( ref.href ); } if ( isValidLink( ref ) && isValidEvent( event ) ) { event.preventDefault(); // Don't start animation if it doesn't take long to navigate. const timeout = setTimeout( () => { ctx.accessibilityMessage = ctx.accessibilityLoadingMessage; ctx.animation = 'start'; }, 400 ); yield navigate( ref.href ); // Clear the timeout if the navigation is fast. clearTimeout( timeout ); // Announce that the page has been loaded. If the message is the // same, we use a no-break space similar to the @wordpress/a11y // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 ctx.accessibilityMessage = ctx.accessibilityLoadedMessage + ( ctx.accessibilityMessage === ctx.accessibilityLoadedMessage ? '\u00A0' : '' ); ctx.animation = 'finish'; ctx.isPrefetchNextOrPreviousLink = !! ref.href; scrollToFirstProductIfNotVisible( wcNavigationId ); triggerProductListRenderedEvent( { collection: ctx.collection, } ); } }, /** * We prefetch the next or previous button page on hover. * Optimizes user experience by preloading content for faster access. */ *prefetchOnHover() { const { ref } = getElement(); const isDisabled = ( ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement )?.dataset.wcNavigationDisabled; if ( isDisabled ) { return; } if ( isValidLink( ref ) ) { yield prefetch( ref.href ); } }, *viewProduct() { const { collection, productId } = getContext< ProductCollectionStoreContext >(); if ( productId ) { triggerViewedProductEvent( { collection, productId } ); } }, }, callbacks: { /** * Prefetches content for next or previous links after initial user interaction. * Reduces perceived load times for subsequent page navigations. */ *prefetch() { const { ref } = getElement(); const isDisabled = ( ref?.closest( '[data-wc-navigation-id]' ) as HTMLDivElement )?.dataset.wcNavigationDisabled; if ( isDisabled ) { return; } const context = getContext< ProductCollectionStoreContext >(); if ( context?.isPrefetchNextOrPreviousLink && isValidLink( ref ) ) { yield prefetch( ref.href ); } }, *onRender() { const { collection } = getContext< ProductCollectionStoreContext >(); triggerProductListRenderedEvent( { collection } ); }, }, }; store( 'woocommerce/product-collection', productCollectionStore );