- Thumbnails now 4:3 aspect ratio instead of square - Renamed thumbnailSize → thumbnailHeight, width calculated from ratio - Default 64px height × 85px width Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
import React from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import type { SxProps, Theme } from '@mui/material/styles';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
/** A single image in the gallery */
|
|
export interface GalleryImage {
|
|
/** Image URL */
|
|
src: string;
|
|
/** Alt text for accessibility */
|
|
alt: string;
|
|
}
|
|
|
|
/** Props for the FA ImageGallery component */
|
|
export interface ImageGalleryProps {
|
|
/** Array of images to display */
|
|
images: GalleryImage[];
|
|
/** Height of the hero image area */
|
|
heroHeight?: number | { xs?: number; sm?: number; md?: number; lg?: number };
|
|
/** Height of each thumbnail (width is 4:3 ratio) */
|
|
thumbnailHeight?: number;
|
|
/** MUI sx prop for style overrides */
|
|
sx?: SxProps<Theme>;
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Image gallery with hero display and thumbnail strip.
|
|
*
|
|
* Shows a large hero image with a row of clickable thumbnails below.
|
|
* Hovering a thumbnail previews it in the hero; clicking locks the
|
|
* selection. First image is selected by default.
|
|
*
|
|
* Used on venue detail, coffin detail, and other product pages.
|
|
*
|
|
* Usage:
|
|
* ```tsx
|
|
* <ImageGallery
|
|
* images={[
|
|
* { src: '/chapel-1.jpg', alt: 'Chapel interior' },
|
|
* { src: '/chapel-2.jpg', alt: 'Chapel exterior' },
|
|
* { src: '/chapel-3.jpg', alt: 'Garden area' },
|
|
* ]}
|
|
* />
|
|
* ```
|
|
*/
|
|
export const ImageGallery = React.forwardRef<HTMLDivElement, ImageGalleryProps>(
|
|
({ images, heroHeight = { xs: 280, md: 420 }, thumbnailHeight = 64, sx }, ref) => {
|
|
const thumbnailWidth = Math.round(thumbnailHeight * (4 / 3));
|
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
|
|
|
|
// The image shown in the hero: hovered thumbnail takes priority over selected
|
|
const displayIndex = hoverIndex ?? selectedIndex;
|
|
const displayImage = images[displayIndex] ?? images[0];
|
|
|
|
if (!images.length) return null;
|
|
|
|
// Single image — no thumbnails needed
|
|
if (images.length === 1) {
|
|
return (
|
|
<Box ref={ref} sx={sx}>
|
|
<Box
|
|
role="img"
|
|
aria-label={displayImage.alt}
|
|
sx={{
|
|
width: '100%',
|
|
height: heroHeight,
|
|
borderRadius: 2,
|
|
backgroundImage: `url(${displayImage.src})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
backgroundColor: 'var(--fa-color-surface-subtle)',
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box ref={ref} sx={sx}>
|
|
{/* Hero image */}
|
|
<Box
|
|
role="img"
|
|
aria-label={displayImage.alt}
|
|
sx={{
|
|
width: '100%',
|
|
height: heroHeight,
|
|
borderRadius: 2,
|
|
backgroundImage: `url(${displayImage.src})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
backgroundColor: 'var(--fa-color-surface-subtle)',
|
|
transition: 'background-image 200ms ease-in-out',
|
|
mb: 1.5,
|
|
}}
|
|
/>
|
|
|
|
{/* Thumbnail strip */}
|
|
<Box
|
|
role="list"
|
|
aria-label="Image gallery thumbnails"
|
|
sx={{
|
|
display: 'flex',
|
|
gap: 1,
|
|
overflowX: 'auto',
|
|
pb: 0.5,
|
|
// Thin horizontal scrollbar for many thumbnails
|
|
scrollbarWidth: 'thin',
|
|
'&::-webkit-scrollbar': { height: 4 },
|
|
'&::-webkit-scrollbar-thumb': {
|
|
background: 'rgba(0,0,0,0.15)',
|
|
borderRadius: 2,
|
|
},
|
|
}}
|
|
>
|
|
{images.map((image, index) => (
|
|
<Box
|
|
key={index}
|
|
role="listitem"
|
|
onClick={() => setSelectedIndex(index)}
|
|
onMouseEnter={() => setHoverIndex(index)}
|
|
onMouseLeave={() => setHoverIndex(null)}
|
|
sx={{
|
|
width: thumbnailWidth,
|
|
height: thumbnailHeight,
|
|
flexShrink: 0,
|
|
borderRadius: 1,
|
|
backgroundImage: `url(${image.src})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
backgroundColor: 'var(--fa-color-surface-subtle)',
|
|
cursor: 'pointer',
|
|
border: '2px solid',
|
|
borderColor: index === selectedIndex ? 'primary.main' : 'transparent',
|
|
opacity: index === selectedIndex ? 1 : 0.7,
|
|
transition: 'border-color 150ms ease-in-out, opacity 150ms ease-in-out',
|
|
'&:hover': {
|
|
opacity: 1,
|
|
borderColor:
|
|
index === selectedIndex ? 'primary.main' : 'var(--fa-color-border-default)',
|
|
},
|
|
}}
|
|
aria-label={image.alt}
|
|
aria-current={index === selectedIndex ? 'true' : undefined}
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setSelectedIndex(index);
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
},
|
|
);
|
|
|
|
ImageGallery.displayName = 'ImageGallery';
|
|
export default ImageGallery;
|