feat: implement ImageLightbox with tests
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user