diff --git a/src/components/Dialogs.tsx b/src/components/Dialogs.tsx index 1eb9f11..d85f9a0 100644 --- a/src/components/Dialogs.tsx +++ b/src/components/Dialogs.tsx @@ -82,7 +82,9 @@ export function Dialogs({
- +
)} @@ -174,7 +176,7 @@ export function Dialogs({ - + )} diff --git a/src/components/PreviewFrame.tsx b/src/components/PreviewFrame.tsx index dd2faf5..e99312c 100644 --- a/src/components/PreviewFrame.tsx +++ b/src/components/PreviewFrame.tsx @@ -1,6 +1,7 @@ // The published-build iframe used by every preview and report overlay, with a // loading state and a load-failure fallback. -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { SyntheticEvent } from 'react' // Backstop for the truly-opaque case (a cross-origin frame, or a server that // rejects HEAD): only fires while still loading. Generous enough that a @@ -14,14 +15,65 @@ export function PreviewFrame({ src, title, frameKey, + autoFocus, + onEscape, }: { className?: string scrolling?: 'no' src: string title: string frameKey?: string + /** + * Focus the iframe once its build loads, so previews that listen for + * computer-keyboard input on their own window (the showcase, candidate + * builds) are playable immediately — without a click inside the frame + * first. Real focus also means the keystrokes are trusted user activation + * inside the frame, which is what lets its AudioContext start. + */ + autoFocus?: boolean + /** + * Called when Escape is pressed *inside* the frame. Once focus is in the + * iframe the parent window never sees the keydown, so same-origin previews + * get a listener attached to their content window; without this, Escape + * would silently stop closing the overlay. + */ + onEscape?: () => void }) { const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading') + // Latest-callback ref: the content-window listener is attached once per + // frame load, but Dialogs passes inline close handlers that change identity + // every render. + const onEscapeRef = useRef(onEscape) + onEscapeRef.current = onEscape + + const handleFrameLoad = (event: SyntheticEvent) => { + // A 404/5xx body still fires onLoad, so never let a late load event + // overwrite the probe's error verdict. + setState((current) => (current === 'error' ? current : 'ready')) + + const frame = event.currentTarget + try { + // Same-origin only (all previews are served under /previews and + // /reports); a cross-origin frame throws and gets skipped. Each + // navigation creates a fresh content window, so re-attach on every + // load — the old window (and its listener) is gone. + frame.contentWindow?.addEventListener('keydown', (keyEvent) => { + if (keyEvent.key !== 'Escape') return + // A preview that consumes Escape itself (the showcase's section-zoom + // dialog preventDefaults it to close just the zoom) keeps the outer + // overlay open — Escape peels one layer at a time. This listener runs + // before the preview's own (it registers at load, theirs later), so + // defer the verdict past the whole dispatch; setTimeout, not a + // microtask, since microtask checkpoints run between listeners. + setTimeout(() => { + if (!keyEvent.defaultPrevented) onEscapeRef.current?.() + }, 0) + }) + } catch { + // Cross-origin frame: Escape inside it can't be observed. + } + if (autoFocus) frame.focus({ preventScroll: true }) + } // Iframe error signaling is unreliable across origins, so a slow build must // not be mistaken for a broken one. Previews are served same-origin under @@ -83,9 +135,7 @@ export function PreviewFrame({ className={className} key={frameKey} onError={() => setState('error')} - // A 404/5xx body still fires onLoad, so never let a late load - // event overwrite the probe's error verdict. - onLoad={() => setState((current) => (current === 'error' ? current : 'ready'))} + onLoad={handleFrameLoad} scrolling={scrolling} src={src} title={title}