Feature/image dialog #8
@@ -41,21 +41,22 @@ describe('ImageLightbox', () => {
|
|||||||
|
|
||||||
it('clicking the close button closes the dialog', () => {
|
it('clicking the close button closes the dialog', () => {
|
||||||
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
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);
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicking the backdrop (dialog element itself) closes the dialog', () => {
|
it('clicking the backdrop (dialog element itself) closes the dialog', () => {
|
||||||
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
const dialog = document.querySelector('dialog')!;
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
fireEvent.click(dialog, { target: dialog });
|
fireEvent.click(dialog, { target: dialog });
|
||||||
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicking inside the dialog (not backdrop) does not close', () => {
|
it('clicking inside the dialog (not backdrop) does not close', () => {
|
||||||
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
const dialog = document.querySelector('dialog')!;
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
const inner = dialog.querySelector('div')!;
|
const inner = dialog.querySelector('div') as HTMLDivElement;
|
||||||
fireEvent.click(inner);
|
fireEvent.click(inner);
|
||||||
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* Image source URL
|
* Image source URL
|
||||||
@@ -18,6 +22,65 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
|
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
|
||||||
*/
|
*/
|
||||||
export function ImageLightbox(_props: Props) {
|
export function ImageLightbox({ src, alt, className }: Props) {
|
||||||
return null;
|
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