Feature/image dialog #8
@@ -154,6 +154,9 @@
|
|||||||
html {
|
html {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
|
||||||
|
* opens) doesn't widen the viewport and shift fixed elements. */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -272,6 +275,12 @@
|
|||||||
@utility brutal-border-right {
|
@utility brutal-border-right {
|
||||||
border-right: var(--border-width) solid var(--blue);
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
|
/* Border drawn as an outline — painted after children, so an image's
|
||||||
|
* subpixel paint bleed can't cover it. Doesn't take layout space; the
|
||||||
|
* ancestor must not have overflow:hidden or the outline gets clipped. */
|
||||||
|
@utility brutal-outline {
|
||||||
|
outline: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
||||||
.font-wonk {
|
.font-wonk {
|
||||||
font-variation-settings:
|
font-variation-settings:
|
||||||
@@ -413,9 +422,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep footer above sliding section-body during view transitions */
|
/* Page-load entry animation for the footer. Previously also carried
|
||||||
|
* `view-transition-name: site-footer` to layer above section-body's slide
|
||||||
|
* group, but the section-body group now has `animation: none` (purely
|
||||||
|
* horizontal slide of OLD/NEW snapshots), so the footer can live in the root
|
||||||
|
* snapshot during transitions — which also lets the lightbox dialog group
|
||||||
|
* stack cleanly above it. */
|
||||||
.footer-vt {
|
.footer-vt {
|
||||||
view-transition-name: site-footer;
|
|
||||||
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
|
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,12 +443,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-group(site-footer) {
|
/* Lightbox dialog backdrop — flat cream wash. No filter, no gradient.
|
||||||
z-index: 10;
|
* Cheapest possible; Firefox-friendly during view transitions. */
|
||||||
|
dialog.lightbox::backdrop {
|
||||||
|
background-color: color-mix(in srgb, var(--cream) 80%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lightbox dialog backdrop */
|
/* Give the lightbox dialog its own view-transition group so it can stack
|
||||||
dialog.lightbox::backdrop {
|
* above the footer's named group (z=10) during the open/close transition.
|
||||||
background-color: rgba(4, 28, 243, 0.25);
|
* Closed dialogs have `display: none` (UA) and don't get snapshotted, so
|
||||||
backdrop-filter: blur(4px);
|
* sharing the name across all `.lightbox` dialogs at rest is harmless —
|
||||||
|
* only the open one participates in any given transition. */
|
||||||
|
dialog.lightbox {
|
||||||
|
view-transition-name: lightbox-dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(lightbox-dialog) {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The image wrapper (and the thumb during the OLD snapshot of an open
|
||||||
|
* transition) shares this static name — imperatively assigned only during
|
||||||
|
* the morph, so there's never more than one element holding it at a time.
|
||||||
|
* z=30 keeps it above the dialog (z=20) and the footer (z=10) during VT. */
|
||||||
|
::view-transition-group(lightbox-frame) {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox image sizing — leaves vertical headroom for the fixed close button
|
||||||
|
* (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering,
|
||||||
|
* so the button never overlaps the image.
|
||||||
|
* box-sizing: content-box so max-w/max-h apply to the image pixels and the
|
||||||
|
* 3px brutal-border sits outside them — avoids subpixel clipping of the
|
||||||
|
* border by the dialog's overflow:hidden when both have border-box. */
|
||||||
|
@utility lightbox-image {
|
||||||
|
box-sizing: content-box;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
max-height: calc(100vh - 8rem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ beforeAll(() => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
document.body.style.overflow = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' };
|
const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' };
|
||||||
@@ -65,5 +66,25 @@ describe('ImageLightbox', () => {
|
|||||||
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project');
|
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opening the lightbox blocks body scroll', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing the lightbox restores body scroll', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close button is positioned fixed', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true });
|
||||||
|
expect(closeBtn).toHaveClass('fixed');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRef } from 'react';
|
import { type SyntheticEvent, useRef } from 'react';
|
||||||
import { CloseIcon } from '$shared/assets/icons';
|
import { CloseIcon } from '$shared/assets/icons';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Button } from '$shared/ui/Button';
|
import { Button } from '$shared/ui/Button';
|
||||||
|
import { Modal, type ModalHandle } from '$shared/ui/Modal';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -19,75 +20,178 @@ type Props = {
|
|||||||
* CSS classes forwarded to the thumbnail button wrapper
|
* CSS classes forwarded to the thumbnail button wrapper
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Skip lazy-loading and preload the thumbnail. Set true for above-the-fold
|
||||||
|
* images to improve LCP.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
priority?: boolean;
|
||||||
|
/**
|
||||||
|
* Responsive `sizes` attribute for the thumbnail. Without this, next/image
|
||||||
|
* `fill` defaults to `100vw` and the browser fetches the largest srcset
|
||||||
|
* variant. Tune to match the actual rendered width at each breakpoint.
|
||||||
|
* @default '(min-width: 1024px) 56rem, 100vw'
|
||||||
|
*/
|
||||||
|
sizes?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
|
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
|
||||||
|
*
|
||||||
|
* Uses the View Transitions API to morph the thumbnail's rect into the dialog
|
||||||
|
* frame's rect (and back on close). The view-transition-name is seated
|
||||||
|
* just-in-time around each transition rather than living on the thumb at rest
|
||||||
|
* — a persistent name would isolate the thumb from any parent transition
|
||||||
|
* (e.g. the section-body slide-in), causing the image to snap into place
|
||||||
|
* while the rest of the section animates.
|
||||||
*/
|
*/
|
||||||
export function ImageLightbox({ src, alt, className }: Props) {
|
export function ImageLightbox({
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
priority = false,
|
||||||
|
sizes = '(min-width: 1024px) 56rem, 100vw',
|
||||||
|
}: Props) {
|
||||||
|
const modalRef = useRef<ModalHandle>(null);
|
||||||
|
const thumbRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dialogFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
/* Shared static name across all instances. Only one dialog can be open at a
|
||||||
|
* time (showModal is browser-exclusive), and we set the name imperatively
|
||||||
|
* only during a transition — so at any snapshot, exactly one element has it. */
|
||||||
|
const vtName = 'lightbox-frame';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the view-transition-name from both thumb and dialog frame. Called
|
||||||
|
* after the lightbox close transition settles (and as a safety net on any
|
||||||
|
* unexpected close path) so the thumb rejoins parent transitions.
|
||||||
|
*/
|
||||||
|
function clearVtNames() {
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `mutate` inside a view transition when supported; falls back to a
|
||||||
|
* plain synchronous call otherwise (Firefox without VT support, jsdom).
|
||||||
|
* Returns the transition handle so callers can await `finished` for cleanup.
|
||||||
|
*/
|
||||||
|
function withTransition(mutate: () => void): { finished: Promise<void> } | null {
|
||||||
|
const doc = document as Document & {
|
||||||
|
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
||||||
|
};
|
||||||
|
if (typeof doc.startViewTransition === 'function') {
|
||||||
|
return doc.startViewTransition(mutate);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
dialogRef.current?.showModal();
|
/* Seat the name on the thumb *before* startViewTransition so it's
|
||||||
|
* captured in the OLD snapshot. The thumb otherwise carries no vt-name. */
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
withTransition(() => {
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
modalRef.current?.open();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
dialogRef.current?.close();
|
const transition = withTransition(() => {
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = '';
|
||||||
}
|
}
|
||||||
|
if (thumbRef.current) {
|
||||||
/**
|
thumbRef.current.style.viewTransitionName = vtName;
|
||||||
* Closes the dialog when the user clicks the backdrop area directly.
|
}
|
||||||
* Comparing target to currentTarget distinguishes a click on the <dialog>
|
modalRef.current?.close();
|
||||||
* element itself (the backdrop) from a click on its content children.
|
});
|
||||||
*/
|
/* Drop the name from the thumb once the transition settles. Otherwise the
|
||||||
function handleBackdropClick(e: React.MouseEvent<HTMLDialogElement>) {
|
* thumb stays its own snapshot until the next open, isolated from any
|
||||||
if (e.target === e.currentTarget) {
|
* parent transition that runs in the meantime. */
|
||||||
close();
|
if (transition) {
|
||||||
|
transition.finished.finally(clearVtNames);
|
||||||
|
} else {
|
||||||
|
clearVtNames();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyboard equivalent of backdrop click — closes the dialog when the user
|
* Intercept ESC so it also runs through our view-transition-wrapped close.
|
||||||
* activates the backdrop area (dialog element itself) via Enter or Space.
|
* Without this, ESC would snap the dialog away without the morph.
|
||||||
* ESC is handled natively by showModal(); this covers explicit backdrop activation.
|
|
||||||
*/
|
*/
|
||||||
function handleBackdropKeyUp(e: React.KeyboardEvent<HTMLDialogElement>) {
|
function handleCancel(e: SyntheticEvent<HTMLDialogElement>) {
|
||||||
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
e.preventDefault();
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={thumbRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={open}
|
onClick={open}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)}
|
className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)}
|
||||||
>
|
>
|
||||||
<Image src={src} alt={alt} fill className="object-cover" />
|
<Image
|
||||||
</button>
|
|
||||||
|
|
||||||
<dialog
|
|
||||||
ref={dialogRef}
|
|
||||||
aria-label={alt}
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
onKeyUp={handleBackdropKeyUp}
|
|
||||||
className="lightbox fixed inset-0 m-auto relative bg-blue brutal-border p-0 overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Native img so the dialog sizes to the image — next/image fill requires a pre-sized container */}
|
|
||||||
{/* aria-hidden: the dialog element itself carries the accessible label */}
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
aria-hidden={true}
|
fill
|
||||||
className="block max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] w-auto h-auto"
|
loading={priority ? 'eager' : undefined}
|
||||||
|
priority={priority}
|
||||||
|
sizes={sizes}
|
||||||
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="absolute top-3 right-3">
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
ref={modalRef}
|
||||||
|
aria-label={alt}
|
||||||
|
className="lightbox bg-cream overflow-visible"
|
||||||
|
onClose={clearVtNames}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onBackdropClose={close}
|
||||||
|
>
|
||||||
|
{/* Wrapper carries the border as an `outline` (not `border`) — paint
|
||||||
|
* order is border→children→outline, so an `outline` is drawn ON TOP
|
||||||
|
* of any subpixel image bleed and stays fully visible. The dialog
|
||||||
|
* uses `overflow-visible` because outline can be clipped by an
|
||||||
|
* ancestor's overflow:hidden.
|
||||||
|
* The wrapper is the named VT element so the brutalist frame
|
||||||
|
* participates in the morph.
|
||||||
|
* aria-hidden: the dialog element itself carries the accessible label. */}
|
||||||
|
<div ref={dialogFrameRef} className="brutal-outline block w-fit">
|
||||||
|
{/* Explicit width/height are placeholders next/image requires when not using `fill`; CSS
|
||||||
|
* (`lightbox-image` max-w/max-h + `w-auto h-auto`) drives the actual rendered size, so
|
||||||
|
* the dialog still hugs the image's intrinsic dimensions (capped at viewport bounds).
|
||||||
|
* `sizes="100vw"` hints the browser to fetch the srcset variant matching viewport width. */}
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={2000}
|
||||||
|
height={2000}
|
||||||
|
loading="lazy"
|
||||||
|
sizes="100vw"
|
||||||
|
aria-hidden={true}
|
||||||
|
className="lightbox-image block w-auto h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="fixed top-3 right-3">
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</dialog>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user