Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn

### Project Documentation

- **[Gallery](./docs/gallery.md)** - Reusable image gallery, empty states, theme support and types

- **[🔧 Mocking](./docs/mocking.md)** - Local mocking and development overrides
- **[🗄️ Cache API](./docs/cache-api.md)** - Caching model and invalidation rules
- **[🎣 Hooks Guide](./docs/hooks.md)** - Custom hooks reference
Expand Down
36 changes: 36 additions & 0 deletions docs/gallery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Gallery

`src/components/gallery/Gallery.tsx` provides the shared client-side Gallery
(issues #1260–#1263). Import `Gallery` and its exported `GalleryProps` type
from `@/components/gallery/Gallery`.

```tsx
<Gallery
images={['/photos/exterior.jpg', '/photos/interior.jpg']}
label="Sunset Villa"
emptyTitle="No photos yet"
emptyDescription="Photos will appear here when they are added."
/>
```

`images` is a required readonly array of URL strings, in display order; use `[]`
when empty. `label` is a required accessible collection name and image description.
`emptyTitle` and `emptyDescription` are optional strings. The default empty state
explains that images will appear when added, has a decorative icon and a status
region, and offers no inactive controls. The property `ImageGallery` adapter keeps
the existing `images` / `propertyName` API and supplies property-specific copy.

The main image, thumbnails and more-images control are native buttons with focus
rings. The more-images button opens the first image beyond the preview. Escape
closes the lightbox; arrow keys navigate it. Replacing the image collection resets
loading and lightbox state. Rendering errors stay inside the existing error boundary.

Gallery surfaces, text, borders and focus rings use the application's semantic
Tailwind tokens (`background`, `foreground`, `muted`, `border`, `ring`, `accent`).
They follow the existing `.dark` theme without a separate toggle or theme state.
The lightbox uses a deliberately dark viewing surface in either theme.

Validation: `npm test -- --runInBand ImageGallery Gallery`, `npm run typecheck`,
and `npm run lint`. The tests cover empty/populated states, keyboard interaction,
lightbox indices, reuse, theme-token integration and collection replacement.
The type assertions in `Gallery.types.ts` are checked by `npm run typecheck`.
78 changes: 78 additions & 0 deletions src/components/gallery/Gallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client';

import { useState } from 'react';
import Image from 'next/image';
import { ImageIcon } from 'lucide-react';
import { ErrorBoundary } from '@/components/ui/SimpleErrorBoundary';
import { ImageLightbox } from './ImageLightbox';

export interface GalleryProps {
/** Image URLs in display order; the first image is featured. */
readonly images: readonly string[];
/** Accessible name and image description for this collection. */
readonly label: string;
readonly emptyTitle?: string;
readonly emptyDescription?: string;
}

/** Reusable image grid with an accessible empty state and lightbox. */
export function Gallery(props: GalleryProps) {
return (
<ErrorBoundary>
<GalleryContent key={JSON.stringify(props.images)} {...props} />
</ErrorBoundary>
);
}

function GalleryContent({
images,
label,
emptyTitle = 'No images available',
emptyDescription = 'Images will appear here when they are added.',
}: GalleryProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [mainImageLoaded, setMainImageLoaded] = useState(false);
const mainImage = images[0];
const focusClasses = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';

if (mainImage === undefined) {
return (
<section aria-label={label} className="flex min-h-[400px] items-center justify-center rounded-xl border border-border bg-muted p-6 text-center text-foreground">
<div role="status">
<ImageIcon aria-hidden="true" className="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
<h3 className="text-lg font-semibold">{emptyTitle}</h3>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{emptyDescription}</p>
</div>
</section>
);
}

return (
<section aria-label={label} className="space-y-4">
<button type="button" aria-label={`View all ${images.length} images of ${label}`} onClick={() => setLightboxIndex(0)} className={`group relative block w-full overflow-hidden rounded-xl bg-muted ${focusClasses}`}>
<Image src={mainImage} alt={label} width={800} height={600} onLoad={() => setMainImageLoaded(true)} onError={() => setMainImageLoaded(true)} className="h-[400px] w-full object-cover transition-transform duration-300 group-hover:scale-105" />
{!mainImageLoaded && <span aria-hidden="true" className="pointer-events-none absolute inset-0 animate-pulse bg-muted" />}
<span className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/20 group-focus-visible:bg-black/20">
<span className="rounded-lg bg-background/95 px-4 py-2 font-medium text-foreground opacity-0 shadow-sm backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">View all {images.length} images</span>
</span>
</button>
{images.length > 1 && (
<div className="grid grid-cols-4 gap-2">
{images.slice(1, 5).map((image, index) => (
<button key={index + 1} type="button" aria-label={`View image ${index + 2} of ${label}`} onClick={() => setLightboxIndex(index + 1)} className={`group relative overflow-hidden rounded-lg bg-muted ${focusClasses}`}>
<Image src={image} alt={`${label} - Image ${index + 2}`} width={200} height={150} className="h-24 w-full object-cover transition-transform duration-300 group-hover:scale-110" />
<span aria-hidden="true" className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/30" />
</button>
))}
{images.length > 5 && (
<button type="button" aria-label={`View ${images.length - 5} more images of ${label}`} onClick={() => setLightboxIndex(5)} className={`flex min-h-24 flex-col items-center justify-center rounded-lg border border-border bg-muted text-foreground hover:bg-accent ${focusClasses}`}>
<span className="text-2xl font-bold">+{images.length - 5}</span>
<span className="text-xs text-muted-foreground">more</span>
</button>
)}
</div>
)}
<ImageLightbox images={images} label={label} initialIndex={lightboxIndex ?? 0} isOpen={lightboxIndex !== null} onClose={() => setLightboxIndex(null)} />
</section>
);
}
18 changes: 18 additions & 0 deletions src/components/gallery/Gallery.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { GalleryProps } from './Gallery';
import type { ImageGalleryProps } from '../property/ImageGallery';

// Compile-time regression checks included in the project's strict typecheck.
export function checkGalleryTypes(props: GalleryProps): void {
// @ts-expect-error Image collections must not be mutated by consumers.
props.images.push('/extra.jpg');
// @ts-expect-error Image URLs must be strings.
const invalidImages: GalleryProps = { images: [123], label: 'Artwork' };
// @ts-expect-error A collection must have an accessible label.
const missingLabel: GalleryProps = { images: [] };
// @ts-expect-error Empty-state copy must be text.
const invalidCopy: GalleryProps = { images: [], label: 'Artwork', emptyTitle: 42 };
const readonlyImages = ['/one.jpg'] as const;
const reusable: GalleryProps = { images: readonlyImages, label: 'Artwork' };
const property: ImageGalleryProps = { images: readonlyImages, propertyName: 'Villa' };
void [invalidImages, missingLabel, invalidCopy, reusable, property];
}
211 changes: 211 additions & 0 deletions src/components/gallery/ImageLightbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
'use client';

import React, { useState, useEffect, useCallback, useRef } from 'react';
import Image from 'next/image';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';

export interface ImageLightboxProps {
images: readonly string[];
label?: string;
initialIndex?: number;
isOpen: boolean;
onClose: () => void;
}

export const ImageLightbox: React.FC<ImageLightboxProps> = ({
images,
label,
initialIndex = 0,
isOpen,
onClose,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);

// Reset index when lightbox opens with new initialIndex
useEffect(() => {
if (isOpen) {
setCurrentIndex(initialIndex);
}
}, [initialIndex, isOpen]);

// Issue #1252: the overlay was a plain div, so a screen reader had no way
// of knowing a dialog had opened, and focus stayed wherever it was behind
// the overlay. Focus now moves into the dialog and is handed back to the
// element that opened it.
useEffect(() => {
if (!isOpen) return;

previouslyFocused.current = document.activeElement as HTMLElement | null;
closeButtonRef.current?.focus();

return () => {
previouslyFocused.current?.focus?.();
};
}, [isOpen]);

// Keyboard navigation
useEffect(() => {
if (!isOpen) return;

const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft':
navigatePrevious();
break;
case 'ArrowRight':
navigateNext();
break;
case 'Escape':
onClose();
break;
}
};

document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';

return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'unset';
};
}, [isOpen, currentIndex]);

// Touch handlers for mobile swipe
const handleTouchStart = useCallback((e: React.TouchEvent) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
}, []);

const handleTouchMove = useCallback((e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX);
}, []);

const handleTouchEnd = useCallback(() => {
if (!touchStart || !touchEnd) return;

const distance = touchStart - touchEnd;
const isLeftSwipe = distance > 50;
const isRightSwipe = distance < -50;

if (isLeftSwipe) {
navigateNext();
}
if (isRightSwipe) {
navigatePrevious();
}
}, [touchStart, touchEnd]);

const navigateNext = () => {
setCurrentIndex((prev) => (prev + 1) % images.length);
};

const navigatePrevious = () => {
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
};

const handleThumbnailClick = (index: number) => {
setCurrentIndex(index);
};

if (!isOpen || images.length === 0) return null;

return (
// The dialog role and aria-modal announce the overlay to assistive tech.
// the rest of the page is behind a modal, and the label names it.
<div
role="dialog"
aria-modal="true"
aria-label={label ?? "Property images"}
className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/90 p-2 sm:p-4"
>
{/* Close Button */}
<button
ref={closeButtonRef}
onClick={onClose}
className="absolute right-2 top-2 z-10 rounded-full bg-white/10 p-1.5 transition-colors hover:bg-white/20 sm:right-4 sm:top-4 sm:p-2"
aria-label="Close lightbox"
>
<X className="w-6 h-6 text-white" />
</button>

{/* Main Image Container */}
<div
className="relative mx-4 w-full max-w-7xl max-h-[70vh] sm:max-h-[80vh]"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<Image
src={images[currentIndex]}
alt={label ? `${label} - Image ${currentIndex + 1}` : `Property image ${currentIndex + 1}`}
width={1200}
height={800}
className="max-w-full max-h-[70vh] object-contain sm:max-h-[80vh]"
priority
/>

{/* Navigation Buttons */}
{images.length > 1 && (
<>
<button
onClick={navigatePrevious}
className="absolute left-0 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-1.5 transition-colors hover:bg-white/20 sm:left-4 sm:p-2"
aria-label="Previous image"
>
<ChevronLeft className="w-6 h-6 text-white" />
</button>
<button
onClick={navigateNext}
className="absolute right-0 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-1.5 transition-colors hover:bg-white/20 sm:right-4 sm:p-2"
aria-label="Next image"
>
<ChevronRight className="w-6 h-6 text-white" />
</button>
</>
)}

{/* Image Counter */}
{/* Issue #1252: the position is announced when it
changes. */}
<div
aria-live="polite"
className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-sm text-white"
>
{currentIndex + 1} / {images.length}
</div>
</div>

{/* Thumbnail Strip */}
{images.length > 1 && (
<div className="absolute bottom-16 left-1/2 flex max-w-[92vw] -translate-x-1/2 gap-2 overflow-x-auto px-4 sm:bottom-20">
{images.map((image, index) => (
<button
key={image}
onClick={() => handleThumbnailClick(index)}
// Issue #1252: the thumbnail that is
// currently shown.
aria-current={index === currentIndex ? "true" : undefined}
className={`flex-shrink-0 relative rounded-lg overflow-hidden border-2 transition-all ${
index === currentIndex
? 'border-blue-500 scale-110 '
: 'border-transparent hover:border-white/50'
}`}
>
<Image
src={image}
alt={`Thumbnail ${index + 1}`}
width={60}
height={45}
className="w-16 h-12 object-cover"
/>
</button>
))}
</div>
)}
</div>
);
};
Loading