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 188 | 1x 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>
);
}
|