From cd59766f926c2a0eb6b3b87fe53e4e43a38711c0 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 09:54:20 +0300 Subject: [PATCH] feat(ImageLightbox): morph to dialog via View Transitions, fix sizing and stacking - Use the shared Modal for dialog mechanics; ImageLightbox keeps only the view-transition coordination (just-in-time view-transition-name handoff between thumb and dialog frame, ESC routed through onCancel). - Switch to for both thumb and dialog image; thumb is priority/sizes- driven for LCP, dialog image is lazy-loaded (deferred until open). - New brutal-outline utility for the dialog frame: outline paints after children so subpixel image bleed can't cover it; dialog gets overflow- visible so the outline isn't clipped. - lightbox-image utility caps image dimensions to viewport minus close-button and footer headroom, with box-sizing: content-box. - Lightbox dialog gets view-transition-name lightbox-dialog (z=20) and the frame gets lightbox-frame (z=30) so both stack cleanly above the page during the open/close transition. Footer drops its named VT group since section-body no longer slides over it. - Cream-tinted backdrop replaces the blur (Firefox-friendly), color-mix with var(--cream) for the token reference. - scrollbar-gutter: stable on html so locking body scroll doesn't shift the layout. --- src/shared/styles/theme.css | 58 +++++- .../ImageLightbox/ui/ImageLightbox.test.tsx | 21 ++ .../ui/ImageLightbox/ui/ImageLightbox.tsx | 184 ++++++++++++++---- 3 files changed, 215 insertions(+), 48 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 8e696e4..513ab00 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -154,6 +154,9 @@ html { font-size: var(--font-size); 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 { @@ -272,6 +275,12 @@ @utility brutal-border-right { 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 */ .font-wonk { 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 { - view-transition-name: site-footer; animation: footer-enter var(--duration-slow) var(--ease-spring) both; } @@ -430,12 +443,41 @@ } } -::view-transition-group(site-footer) { - z-index: 10; +/* Lightbox dialog backdrop — flat cream wash. No filter, no gradient. + * Cheapest possible; Firefox-friendly during view transitions. */ +dialog.lightbox::backdrop { + background-color: color-mix(in srgb, var(--cream) 80%, transparent); } -/* Lightbox dialog backdrop */ -dialog.lightbox::backdrop { - background-color: rgba(4, 28, 243, 0.25); - backdrop-filter: blur(4px); +/* Give the lightbox dialog its own view-transition group so it can stack + * above the footer's named group (z=10) during the open/close transition. + * Closed dialogs have `display: none` (UA) and don't get snapshotted, so + * 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); } diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx index 9fb2261..2bf7a99 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx @@ -9,6 +9,7 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks(); + document.body.style.overflow = ''; }); const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' }; @@ -65,5 +66,25 @@ describe('ImageLightbox', () => { render(); expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project'); }); + + it('opening the lightbox blocks body scroll', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'My Project' })); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('closing the lightbox restores body scroll', () => { + render(); + 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(); + const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true }); + expect(closeBtn).toHaveClass('fixed'); + }); }); }); diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx index 73becfc..358080e 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx @@ -1,10 +1,11 @@ 'use client'; import Image from 'next/image'; -import { useRef } from 'react'; +import { type SyntheticEvent, useRef } from 'react'; import { CloseIcon } from '$shared/assets/icons'; import { cn } from '$shared/lib'; import { Button } from '$shared/ui/Button'; +import { Modal, type ModalHandle } from '$shared/ui/Modal'; type Props = { /** @@ -19,75 +20,178 @@ type Props = { * CSS classes forwarded to the thumbnail button wrapper */ 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. + * + * 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) { - const dialogRef = useRef(null); +export function ImageLightbox({ + src, + alt, + className, + priority = false, + sizes = '(min-width: 1024px) 56rem, 100vw', +}: Props) { + const modalRef = useRef(null); + const thumbRef = useRef(null); + const dialogFrameRef = useRef(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 } | null { + const doc = document as Document & { + startViewTransition?: (cb: () => void) => { finished: Promise }; + }; + if (typeof doc.startViewTransition === 'function') { + return doc.startViewTransition(mutate); + } + mutate(); + return null; + } 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() { - dialogRef.current?.close(); - } - - /** - * Closes the dialog when the user clicks the backdrop area directly. - * Comparing target to currentTarget distinguishes a click on the - * element itself (the backdrop) from a click on its content children. - */ - function handleBackdropClick(e: React.MouseEvent) { - if (e.target === e.currentTarget) { - close(); + const transition = withTransition(() => { + if (dialogFrameRef.current) { + dialogFrameRef.current.style.viewTransitionName = ''; + } + if (thumbRef.current) { + thumbRef.current.style.viewTransitionName = vtName; + } + modalRef.current?.close(); + }); + /* Drop the name from the thumb once the transition settles. Otherwise the + * thumb stays its own snapshot until the next open, isolated from any + * parent transition that runs in the meantime. */ + if (transition) { + transition.finished.finally(clearVtNames); + } else { + clearVtNames(); } } /** - * 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. + * Intercept ESC so it also runs through our view-transition-wrapped close. + * Without this, ESC would snap the dialog away without the morph. */ - function handleBackdropKeyUp(e: React.KeyboardEvent) { - if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { - close(); - } + function handleCancel(e: SyntheticEvent) { + e.preventDefault(); + close(); } return ( <> - - - {/* 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 */} - {alt} - + + + {/* 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. */} +
+ {/* 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. */} + {alt} +
+ -
+ ); }