diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 4731e7d5a..5c82a3e6a 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -18,7 +18,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Download, Pencil, Plus, Trash2 } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; @@ -78,6 +78,40 @@ function InlinePreview({ const [loaded, setLoaded] = useState(false); const src = `/api/documents/inbound/${documentId}/original`; const isPdf = mimeType === "application/pdf"; + // Chromium's PDF viewer grabs focus while it initialises — BEFORE the + // frame's `load` event — stranding keyboard events inside the embedded + // document, so Escape stops closing the sheet the moment a PDF preview + // opens. While the guard window is open (armed at mount, extended at + // load, ended early by a deliberate pointer interaction with the + // preview), any focus landing on the frame bounces straight back to the + // dialog container; a user who clicks into the PDF is left alone. + const frameRef = useRef(null); + const stealGuardUntil = useRef(0); + const reclaimFocus = (frame: HTMLIFrameElement) => { + if ( + Date.now() < stealGuardUntil.current && + document.activeElement === frame + ) { + frame.closest('[role="dialog"]')?.focus(); + } + }; + useEffect(() => { + // Arm at mount, then run a frame-by-frame sweep for the steal variants + // that never dispatch a focus event on the frame element. + // Self-terminates with the guard. + stealGuardUntil.current = Date.now() + 3_000; + let raf = 0; + const sweep = () => { + const frame = frameRef.current; + if (!frame || Date.now() >= stealGuardUntil.current) return; + if (document.activeElement === frame) { + frame.closest('[role="dialog"]')?.focus(); + } + raf = requestAnimationFrame(sweep); + }; + raf = requestAnimationFrame(sweep); + return () => cancelAnimationFrame(raf); + }, []); return (
@@ -94,27 +128,23 @@ function InlinePreview({ // upload policy denies every format whose inline render could // execute script.