ChemPal Documentation - v1.6.0
    Preparing search index...
    • Renders a product's image in the fixed-size detail box, cycling through the images on a fixed interval (IMAGE_CYCLE_MS) when there is more than one. Hovering the image reveals semi-transparent prev/next arrows for manual navigation; a click anywhere else opens the full image in a new tab. Images that fail to load (e.g. a CACTUS 404) are dropped from the rotation; the box disappears only once every image has failed.

      Parameters

      Returns null | ReactElement<unknown, string | JSXElementConstructor<any>>

      The image box, or null when there are no loadable images.

      <ProductImageCarousel images={resolveProductImages(product)} title={product.title} />
      
      export function ProductImageCarousel({
      images,
      title,
      }: ProductImageCarouselProps): ReactElement | null {
      const [broken, setBroken] = useState<ReadonlySet<number>>(new Set());
      const [modalOpen, setModalOpen] = useState(false);
      // URLs already handed to the preloader, so we never fetch the same one twice.
      const preloadedRef = useRef<Set<string>>(new Set());

      const available = images
      .map((image, index) => ({ image, index }))
      .filter(({ index }) => !broken.has(index));

      const { index: position, next, prev } = useCyclingIndex(available.length, IMAGE_CYCLE_MS);

      // The thumbnails the carousel actually renders, in cycle order. The first is shown
      // immediately (loaded by the <img> below), so we preload only the rest — ahead of the
      // automatic rotation — and the key keeps the effect stable across value-equal renders.
      const thumbnails = available.map(({ image }) => image.thumbSrc).filter(Boolean);
      const thumbnailKey = thumbnails.join('\n');

      useEffect(() => {
      const urls = thumbnailKey.length > 0 ? thumbnailKey.split('\n') : [];
      const pending = urls.slice(1).filter((url) => !preloadedRef.current.has(url));
      if (pending.length === 0) return;
      for (const url of pending) preloadedRef.current.add(url);
      void preloadImages(pending);
      }, [thumbnailKey]);

      if (available.length === 0) return null;

      const current = available[Math.min(position, available.length - 1)];
      const hasMultiple = available.length > 1;
      // Cycle without letting the click bubble to the trigger that opens the modal.
      const navigate = (event: MouseEvent, step: () => void) => {
      event.preventDefault();
      event.stopPropagation();
      step();
      };

      return (
      <Fragment>
      <ProductDetailImageBox>
      <ProductImageTrigger
      type="button"
      aria-label={i18n('product_detail_view_images', [title])}
      onClick={() => setModalOpen(true)}
      >
      <img
      src={current.image.thumbSrc}
      alt={current.image.altText ?? title}
      onError={() => setBroken((brokenSet) => new Set(brokenSet).add(current.index))}
      />
      </ProductImageTrigger>
      {hasMultiple && (
      <>
      <ProductImageNavButton
      type="button"
      className="image-nav prev"
      aria-label={i18n('product_detail_prev_image')}
      onClick={(event) => navigate(event, prev)}
      >
      <ArrowBackIosNewIcon fontSize="small" />
      </ProductImageNavButton>
      <ProductImageNavButton
      type="button"
      className="image-nav next"
      aria-label={i18n('product_detail_next_image')}
      onClick={(event) => navigate(event, next)}
      >
      <ArrowForwardIosIcon fontSize="small" />
      </ProductImageNavButton>
      </>
      )}
      </ProductDetailImageBox>
      <ProductImageModal
      open={modalOpen}
      onClose={() => setModalOpen(false)}
      images={available.map(({ image }) => image)}
      title={title}
      initialIndex={position}
      />
      </Fragment>
      );
      }