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:
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { ImageGallery } from './ImageGallery';
|
||||
import type { GalleryImage } from './ImageGallery';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
const venueImages: GalleryImage[] = [
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&h=600&fit=crop',
|
||||
alt: 'Chapel interior with natural light',
|
||||
},
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?w=800&h=600&fit=crop',
|
||||
alt: 'Chapel exterior and gardens',
|
||||
},
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=800&h=600&fit=crop',
|
||||
alt: 'Reception hall',
|
||||
},
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=800&h=600&fit=crop',
|
||||
alt: 'Lakeside view',
|
||||
},
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop',
|
||||
alt: 'Mountain chapel',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof ImageGallery> = {
|
||||
title: 'Molecules/ImageGallery',
|
||||
component: ImageGallery,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ImageGallery>;
|
||||
|
||||
/** Default — multiple images with thumbnail strip */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
images: venueImages,
|
||||
},
|
||||
};
|
||||
|
||||
/** Single image — no thumbnail strip shown */
|
||||
export const SingleImage: Story = {
|
||||
args: {
|
||||
images: [venueImages[0]],
|
||||
},
|
||||
};
|
||||
|
||||
/** Two images */
|
||||
export const TwoImages: Story = {
|
||||
args: {
|
||||
images: venueImages.slice(0, 2),
|
||||
},
|
||||
};
|
||||
|
||||
/** Custom hero height and thumbnail size */
|
||||
export const CustomSizes: Story = {
|
||||
args: {
|
||||
images: venueImages,
|
||||
heroHeight: 300,
|
||||
thumbnailSize: 56,
|
||||
},
|
||||
};
|
||||
163
src/components/molecules/ImageGallery/ImageGallery.tsx
Normal file
163
src/components/molecules/ImageGallery/ImageGallery.tsx
Normal 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;
|
||||
2
src/components/molecules/ImageGallery/index.ts
Normal file
2
src/components/molecules/ImageGallery/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ImageGallery, default } from './ImageGallery';
|
||||
export type { ImageGalleryProps, GalleryImage } from './ImageGallery';
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { WizardLayout } from '../../templates/WizardLayout';
|
||||
import { ImageGallery } from '../../molecules/ImageGallery';
|
||||
import type { GalleryImage } from '../../molecules/ImageGallery';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
|
||||
@@ -17,6 +19,8 @@ export interface CoffinSpec {
|
||||
export interface CoffinProfile {
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
/** Additional gallery images (if provided, imageUrl becomes the first) */
|
||||
images?: GalleryImage[];
|
||||
description?: string;
|
||||
specs?: CoffinSpec[];
|
||||
price: number;
|
||||
@@ -163,20 +167,15 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{/* Left panel: image + description */}
|
||||
<Box
|
||||
role="img"
|
||||
aria-label={`Photo of ${coffin.name}`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: { xs: 280, md: 400 },
|
||||
borderRadius: 2,
|
||||
backgroundImage: `url(${coffin.imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundColor: 'var(--fa-color-surface-subtle)',
|
||||
mb: 3,
|
||||
}}
|
||||
{/* Left panel: image gallery + description */}
|
||||
<ImageGallery
|
||||
images={
|
||||
coffin.images && coffin.images.length > 0
|
||||
? coffin.images
|
||||
: [{ src: coffin.imageUrl, alt: `Photo of ${coffin.name}` }]
|
||||
}
|
||||
heroHeight={{ xs: 280, md: 400 }}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{coffin.description && (
|
||||
|
||||
@@ -37,6 +37,24 @@ const sampleVenue: VenueDetail = {
|
||||
id: 'west-chapel',
|
||||
name: 'West Chapel',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&h=600&fit=crop',
|
||||
images: [
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&h=600&fit=crop',
|
||||
alt: 'Chapel interior',
|
||||
},
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?w=800&h=600&fit=crop',
|
||||
alt: 'Garden area',
|
||||
},
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=800&h=600&fit=crop',
|
||||
alt: 'Reception hall',
|
||||
},
|
||||
{
|
||||
src: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=800&h=600&fit=crop',
|
||||
alt: 'Lakeside pavilion',
|
||||
},
|
||||
],
|
||||
location: 'Strathfield',
|
||||
venueType: 'Indoor Chapel',
|
||||
capacity: 120,
|
||||
|
||||
@@ -6,6 +6,8 @@ import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { WizardLayout } from '../../templates/WizardLayout';
|
||||
import { ImageGallery } from '../../molecules/ImageGallery';
|
||||
import type { GalleryImage } from '../../molecules/ImageGallery';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import { Chip } from '../../atoms/Chip';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
@@ -19,6 +21,8 @@ export interface VenueDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
/** Additional gallery images (if provided, imageUrl becomes the first) */
|
||||
images?: GalleryImage[];
|
||||
location: string;
|
||||
venueType?: string;
|
||||
capacity?: number;
|
||||
@@ -221,20 +225,15 @@ export const VenueDetailStep: React.FC<VenueDetailStepProps> = ({
|
||||
>
|
||||
{/* ═══════ LEFT PANEL: scrollable content ═══════ */}
|
||||
|
||||
{/* ─── Hero image ─── */}
|
||||
<Box
|
||||
role="img"
|
||||
aria-label={`Photo of ${venue.name}`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: { xs: 280, md: 420 },
|
||||
borderRadius: 2,
|
||||
backgroundImage: `url(${venue.imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundColor: 'var(--fa-color-surface-subtle)',
|
||||
mb: 3,
|
||||
}}
|
||||
{/* ─── Image gallery ─── */}
|
||||
<ImageGallery
|
||||
images={
|
||||
venue.images && venue.images.length > 0
|
||||
? venue.images
|
||||
: [{ src: venue.imageUrl, alt: `Photo of ${venue.name}` }]
|
||||
}
|
||||
heroHeight={{ xs: 280, md: 420 }}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{/* ─── Description ─── */}
|
||||
|
||||
Reference in New Issue
Block a user