Feature/image dialog #8

Merged
ilia merged 19 commits from feature/image-dialog into main 2026-05-23 10:16:55 +00:00
2 changed files with 70 additions and 6 deletions
Showing only changes of commit bfb0b46a37 - Show all commits
@@ -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>
</>
);
} }