All files / src/components/SearchPanel ProductImageModal.tsx

100% Statements 95/95
91.3% Branches 21/23
100% Functions 9/9
100% Lines 95/95

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 1881x                 1x   1x 1x 1x 1x 1x 1x                                     1x                     1x 42x 42x 42x 42x   1x                                                 1x 1x 33x 33x 33x 33x 33x 33x 33x 33x 33x 33x 33x 33x   33x 20x 9x 9x 20x 33x   33x 33x 33x 33x   33x 5x 5x 5x   33x 33x 33x 33x 33x 33x 33x   33x 33x 33x 33x 33x   33x 33x 33x   33x 33x 33x 33x   33x       33x 33x 33x 33x 33x 33x   33x 33x 33x 33x 33x 33x 33x   33x   33x 33x 33x 33x 33x   33x             33x 33x 33x 33x 33x 33x 33x 33x 33x 33x     33x               33x  
import {
  ProductImageModalBox,
  ProductImageModalClose,
  ProductImageModalHeader,
  ProductImageModalNavButton,
  ProductImageModalStage,
  ProductImageThumb,
  ProductImageThumbStrip,
} from '@/components/StyledComponents';
import { i18n } from '@/helpers/i18n';
import type { ResolvedProductImage } from '@/helpers/product';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import CloseIcon from '@mui/icons-material/Close';
import Modal from '@mui/material/Modal';
import Typography from '@mui/material/Typography';
import { useEffect, useRef, useState, type ReactElement } from 'react';
 
/**
 * Props for {@link ProductImageModal}.
 * @source
 */
interface ProductImageModalProps {
  /** Whether the gallery modal is visible. */
  open: boolean;
  /** Invoked on backdrop click, Esc, or the close control. */
  onClose: () => void;
  /** The loadable images to browse, in display order. */
  images: ResolvedProductImage[];
  /** Product title, used for the dialog label and image alt fallback. */
  title: string;
  /** Index (into `images`) to show first, captured when the modal opens. */
  initialIndex: number;
}
 
/**
 * Clamp an index into the `[0, length)` range, returning `0` for an empty list.
 * @param value - The candidate index.
 * @param length - The number of items being indexed.
 * @returns The clamped index, always a valid position (or `0` when empty).
 * @example
 * ```ts
 * clampIndex(5, 3); // => 2
 * clampIndex(-1, 3); // => 0
 * ```
 * @source
 */
function clampIndex(value: number, length: number): number {
  if (length <= 0) return 0;
  return Math.min(Math.max(value, 0), length - 1);
}
 
/**
 * Full-screen-ish gallery modal for a product's images: an enlarged image with
 * prev/next arrows and a thumbnail strip beneath it whose thumbnails load the
 * selected image into the enlarged view. Opened from the detail-panel carousel,
 * seeded to the image the user clicked. Unlike the inline carousel it does not
 * auto-advance — navigation is manual.
 *
 * The enlarged view shows each image's larger `fullSrc`; if that fails to load
 * (e.g. a generated structure depiction 404s) it falls back to the same slot's
 * already-loaded `thumbSrc`.
 * @component
 * @category Components
 * @param props - The modal props (see {@link ProductImageModalProps}).
 * @returns The gallery modal element.
 * @example
 * ```tsx
 * <ProductImageModal
 *   open={open}
 *   onClose={close}
 *   images={resolveProductImages(product)}
 *   title={product.title}
 *   initialIndex={0}
 * />
 * ```
 * @source
 */
export function ProductImageModal({
  open,
  onClose,
  images,
  title,
  initialIndex,
}: ProductImageModalProps): ReactElement {
  const [selected, setSelected] = useState(0);
  const [broken, setBroken] = useState<ReadonlySet<number>>(new Set());
  // Tracks the previous `open` value so the initial index is seeded only on the
  // closed→open edge — the inline carousel keeps auto-cycling while the modal is
  // open, and re-seeding on every `initialIndex` tick would make it jump.
  const wasOpen = useRef(false);
 
  useEffect(() => {
    if (open && !wasOpen.current) {
      setSelected(clampIndex(initialIndex, images.length));
    }
    wasOpen.current = open;
  }, [open, initialIndex, images.length]);
 
  const count = images.length;
  const position = clampIndex(selected, count);
  const current = images[position];
  const hasMultiple = count > 1;
 
  const step = (delta: number) => {
    if (count <= 1) return;
    setSelected((index) => (index + delta + count) % count);
  };
 
  return (
    <Modal
      data-testid="product-image-modal"
      open={open}
      onClose={onClose}
      aria-labelledby="product-image-modal-title"
      sx={{ zIndex: (theme) => theme.zIndex.modal + 10 }}
    >
      <ProductImageModalBox
        role="dialog"
        aria-modal="true"
        aria-labelledby="product-image-modal-title"
        onClick={(event) => event.stopPropagation()}
      >
        <ProductImageModalHeader>
          <Typography id="product-image-modal-title" variant="subtitle1" component="h2" noWrap>
            {i18n('product_image_modal_title', [title])}
          </Typography>
          <ProductImageModalClose
            type="button"
            aria-label={i18n('product_image_modal_close')}
            onClick={onClose}
          >
            <CloseIcon fontSize="small" />
          </ProductImageModalClose>
        </ProductImageModalHeader>
 
        {current !== undefined && (
          <ProductImageModalStage>
            <img
              src={broken.has(position) ? current.thumbSrc : current.fullSrc}
              alt={current.altText ?? title}
              onError={() => setBroken((set) => new Set(set).add(position))}
            />
            {hasMultiple && (
              <>
                <ProductImageModalNavButton
                  type="button"
                  className="prev"
                  aria-label={i18n('product_detail_prev_image')}
                  onClick={() => step(-1)}
                >
                  <ArrowBackIosNewIcon fontSize="small" />
                </ProductImageModalNavButton>
                <ProductImageModalNavButton
                  type="button"
                  className="next"
                  aria-label={i18n('product_detail_next_image')}
                  onClick={() => step(1)}
                >
                  <ArrowForwardIosIcon fontSize="small" />
                </ProductImageModalNavButton>
              </>
            )}
          </ProductImageModalStage>
        )}
 
        {hasMultiple && (
          <ProductImageThumbStrip>
            {images.map((image, index) => (
              <ProductImageThumb
                key={image.thumbSrc + index}
                type="button"
                className={index === position ? 'active' : undefined}
                aria-current={index === position ? 'true' : undefined}
                aria-label={i18n('product_image_modal_thumbnail', [String(index + 1)])}
                onClick={() => setSelected(index)}
              >
                {/* Decorative: the button's aria-label already names the image. */}
                <img src={image.thumbSrc} alt="" />
              </ProductImageThumb>
            ))}
          </ProductImageThumbStrip>
        )}
      </ProductImageModalBox>
    </Modal>
  );
}