ImageGallery molecule + wire into VenueDetailStep and CoffinDetailsStep

New ImageGallery molecule:
- Hero display area with thumbnail strip below
- Hover thumbnail to preview in hero, click to lock selection
- First image selected by default, brand border on active thumb
- Keyboard accessible (Enter/Space to select)
- Single image mode (no thumbnails), horizontal scroll for many
- Stories: Default, SingleImage, TwoImages, CustomSizes

VenueDetailStep + CoffinDetailsStep:
- Replaced static hero Box with ImageGallery component
- Added optional images[] field to VenueDetail and CoffinProfile types
- Falls back to single imageUrl when images not provided

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 21:05:58 +11:00
parent 82231ee124
commit 3f21964bb7
6 changed files with 285 additions and 28 deletions

View File

@@ -0,0 +1,163 @@
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 */
thumbnailSize?: 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 }, thumbnailSize = 72, sx }, ref) => {
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: thumbnailSize,
height: thumbnailSize,
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;