feat: implement ImageLightbox with tests

This commit is contained in:
Ilia Mashkov
2026-05-22 12:01:08 +03:00
parent c7ed458c8e
commit bfb0b46a37
2 changed files with 70 additions and 6 deletions
@@ -41,21 +41,22 @@ describe('ImageLightbox', () => {
it('clicking the close button closes the dialog', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
fireEvent.click(screen.getByRole('button', { name: /close/i }));
fireEvent.click(screen.getByRole('button', { name: 'My Project' })); // open first
fireEvent.click(screen.getByRole('button', { name: /close/i, hidden: true }));
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
});
it('clicking the backdrop (dialog element itself) closes the dialog', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
const dialog = document.querySelector('dialog')!;
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent.click(dialog, { target: dialog });
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
});
it('clicking inside the dialog (not backdrop) does not close', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
const dialog = document.querySelector('dialog')!;
const inner = dialog.querySelector('div')!;
const dialog = document.querySelector('dialog') as HTMLDialogElement;
const inner = dialog.querySelector('div') as HTMLDivElement;
fireEvent.click(inner);
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
});
@@ -1,5 +1,9 @@
'use client';
import Image from 'next/image';
import { useRef } from 'react';
import { cn } from '$shared/lib';
type Props = {
/**
* Image source URL
@@ -18,6 +22,65 @@ type Props = {
/**
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
*/
export function ImageLightbox(_props: Props) {
return null;
export function ImageLightbox({ src, alt, className }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
function open() {
dialogRef.current?.showModal();
}
function close() {
dialogRef.current?.close();
}
function handleBackdropClick(e: React.MouseEvent<HTMLDialogElement>) {
if (e.target === e.currentTarget) {
close();
}
}
/**
* Keyboard equivalent of backdrop click — closes the dialog when the user
* activates the backdrop area (dialog element itself) via Enter or Space.
* ESC is handled natively by showModal(); this covers explicit backdrop activation.
*/
function handleBackdropKeyUp(e: React.KeyboardEvent<HTMLDialogElement>) {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
close();
}
}
return (
<>
<button
type="button"
onClick={open}
aria-label={alt}
className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)}
>
<Image src={src} alt={alt} fill className="object-cover" />
</button>
<dialog
ref={dialogRef}
aria-label={alt}
onClick={handleBackdropClick}
onKeyUp={handleBackdropKeyUp}
className="lightbox bg-blue brutal-border shadow-brutal-xl p-0 max-w-5xl w-full"
>
<div className="relative aspect-video w-full">
{/* aria-hidden: the dialog element itself carries the accessible label */}
<Image src={src} alt={alt} fill className="object-contain" aria-hidden={true} />
</div>
<button
type="button"
onClick={close}
aria-label="Close image"
className="absolute top-3 right-3 bg-blue text-cream brutal-border px-3 py-1 text-sm font-bold"
>
Close
</button>
</dialog>
</>
);
}