Files
WebForm-mw/components/report/AttachmentLightbox.tsx
T
Joel Brock e4085aa0f3 Lightbox: pin and size to visible viewport when framed
The staff-report iframe is expanded to full content height (no nested
scrollbar), so a modal <dialog> centered in its viewport landed in the
middle of the whole document and sat off-screen unless the parent page
was scrolled there. Cross-origin means the iframe can't read the
parent's scroll position itself.

Tab.tpl now broadcasts the iframe's visible slice (webform-mw-viewport,
rAF-throttled on scroll/resize/load and after each height change), and
AttachmentLightbox pins to that slice and fills its height. Falls back
to a fixed box when no viewport message arrives (older extension), and
standalone mode keeps native centering at a taller 85vh.

Extension bumped to 0.3.1 (template change -> re-copy + cv flush on
prod). PRODUCTION_CUTOVER.md change log updated.
2026-06-19 08:03:40 -07:00

183 lines
6.8 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
/**
* Modal preview for image and PDF attachments.
*
* Uses the native <dialog> element for focus trap, Esc-to-close, and
* inert-background semantics — saves ~100 lines of bespoke a11y wiring
* that we'd otherwise have to maintain.
*
* For an `image/*` mime, renders an <img>. For `application/pdf`, an
* <iframe>. Anything else should not reach this component — FileLink is
* responsible for branching office/other types to plain download links.
*/
export function AttachmentLightbox({
open,
onClose,
previewSrc,
downloadHref,
filename,
mime,
}: {
open: boolean;
onClose: () => void;
/** URL the <img>/<iframe> loads from. Should serve with Content-Disposition: inline. */
previewSrc: string;
/** Anchor target for the Download button. Serves with Content-Disposition: attachment. */
downloadHref: string;
filename: string;
/** Resolved mime; used to pick between <img> and <iframe>. */
mime: string;
}) {
const ref = useRef<HTMLDialogElement | null>(null);
// When embedded in the CiviCRM tab, the iframe is auto-expanded to its full
// content height (no nested scrollbar), so a modal <dialog> — which centers
// in *its* viewport — lands in the vertical middle of the whole document and
// is off-screen unless the parent page happens to be scrolled there. The
// iframe can't read the parent's scroll position (cross-origin), so the
// extension's tab template broadcasts the iframe's currently-visible slice
// as `webform-mw-viewport` messages. We pin the dialog to that slice and
// size it to fill the visible height. Standalone (non-embedded) keeps the
// native viewport centering.
const [framed, setFramed] = useState(false);
const vpRef = useRef<{ top: number; height: number } | null>(null);
const [vp, setVp] = useState<{ top: number; height: number } | null>(null);
useEffect(() => {
setFramed(window.parent !== window);
}, []);
useEffect(() => {
const onMsg = (e: MessageEvent) => {
if (e.source !== window.parent) return;
const d = e.data as { type?: string; top?: number; height?: number } | null;
if (!d || d.type !== "webform-mw-viewport") return;
const next = { top: Number(d.top) || 0, height: Number(d.height) || 0 };
vpRef.current = next;
// Only reflect into render state while open — many FileLinks mount a
// (closed) lightbox each, and we don't want every one re-rendering on
// each scroll frame.
if (open) setVp(next);
};
window.addEventListener("message", onMsg);
return () => window.removeEventListener("message", onMsg);
}, [open]);
// Seed from the latest known viewport the moment we open.
useEffect(() => {
if (open) setVp(vpRef.current);
}, [open]);
// Drive the native <dialog>'s open state from our prop.
useEffect(() => {
const dlg = ref.current;
if (!dlg) return;
if (open && !dlg.open) {
dlg.showModal();
} else if (!open && dlg.open) {
dlg.close();
}
}, [open]);
// Native <dialog> fires a 'close' event on Esc and on form-method=dialog
// submit. Mirror that back into React state so the parent stays in sync.
useEffect(() => {
const dlg = ref.current;
if (!dlg) return;
const handle = () => onClose();
dlg.addEventListener("close", handle);
return () => dlg.removeEventListener("close", handle);
}, [onClose]);
// Close when the user clicks the backdrop (everything outside the inner
// panel). The dialog itself receives the click event when the backdrop
// is hit because the panel uses pointer-events the same way.
const onDialogClick = (e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === ref.current) onClose();
};
const isImage = mime.startsWith("image/");
const isPdf = mime === "application/pdf";
// Vertical placement + height.
// - framed + known viewport: pin to the visible slice and fill it.
// - framed but no viewport yet (e.g. an older extension that doesn't
// broadcast): fall back to a safe fixed box so we never balloon to the
// full multi-thousand-pixel iframe height.
// - standalone: native viewport centering, tall enough for documents.
const margin = 16;
const dialogStyle: React.CSSProperties | undefined =
framed && vp
? { top: Math.max(8, vp.top + margin), bottom: "auto", marginTop: 0, marginBottom: 0 }
: undefined;
const panelHeight = framed
? vp
? Math.max(360, vp.height - margin * 2)
: 640
: undefined;
return (
<dialog
ref={ref}
onClick={onDialogClick}
aria-label={`Preview: ${filename}`}
style={dialogStyle}
className="m-auto w-[min(92vw,900px)] rounded-md bg-transparent p-0 shadow-2xl backdrop:bg-ink/70"
>
<div
style={panelHeight !== undefined ? { height: panelHeight } : undefined}
className={`flex flex-col overflow-hidden rounded-md ${framed ? "" : "h-[85vh] max-h-[860px]"}`}
>
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-2.5 sm:px-5">
<p className="min-w-0 truncate font-display text-sm text-ink">
{filename}
</p>
<div className="flex flex-shrink-0 items-center gap-3">
<a
href={downloadHref}
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
target="_blank"
rel="noopener noreferrer"
>
Download
</a>
<button
type="button"
onClick={onClose}
className="rounded px-2 py-1 text-xs font-medium text-ink-soft hover:bg-rule-soft/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
aria-label="Close preview"
>
Close
</button>
</div>
</header>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-ink/90 p-3">
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewSrc}
alt={filename}
className="max-h-full max-w-full object-contain"
/>
) : isPdf ? (
<iframe
src={previewSrc}
title={filename}
className="h-full w-full border-0 bg-paper"
/>
) : (
// Defensive: FileLink shouldn't open the lightbox for non-previewable
// types, but if it does, surface a clear message instead of an empty box.
<p className="px-6 text-paper">
Preview not available. Use Download above to open the file.
</p>
)}
</div>
</div>
</dialog>
);
}