diff --git a/apps/web/src/components/PreviewModal.tsx b/apps/web/src/components/PreviewModal.tsx index 161190739..14dcafd37 100644 --- a/apps/web/src/components/PreviewModal.tsx +++ b/apps/web/src/components/PreviewModal.tsx @@ -72,6 +72,22 @@ export function PreviewModal({ return () => document.removeEventListener('keydown', onKey); }, [onClose, fullscreen]); + // Mirror native fullscreen state into React. Without this, a user in + // browser fullscreen has to press Esc twice: the first Esc exits the + // native fullscreen element (consumed by the browser; in some browsers no + // keydown is delivered) while our `fullscreen` state stays true and the + // overlay keeps its `ds-modal-fullscreen` class. Listening to + // fullscreenchange lets one Esc dismiss both layers in lock-step. + useEffect(() => { + const onFsChange = () => { + if (!document.fullscreenElement) { + setFullscreen(false); + } + }; + document.addEventListener('fullscreenchange', onFsChange); + return () => document.removeEventListener('fullscreenchange', onFsChange); + }, []); + // Close share popover on outside click / Escape. useEffect(() => { if (!shareOpen) return; diff --git a/e2e/tests/preview-modal-fullscreen.test.tsx b/e2e/tests/preview-modal-fullscreen.test.tsx new file mode 100644 index 000000000..54927109a --- /dev/null +++ b/e2e/tests/preview-modal-fullscreen.test.tsx @@ -0,0 +1,109 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PreviewModal } from '../../apps/web/src/components/PreviewModal'; + +// Regression coverage for nexu-io/open-design#141: pressing Esc in fullscreen +// used to require two presses because the browser exits its native fullscreen +// element on the first press without delivering a keydown to JS, leaving the +// React `fullscreen` state stuck on. The fix listens to fullscreenchange and +// mirrors the native state into React. + +const baseProps = { + title: 'Sample', + views: [{ id: 'main', label: 'Main', html: '

hi

' }], + exportTitleFor: (id: string) => id, +}; + +function dispatchFullscreenChange() { + act(() => { + document.dispatchEvent(new Event('fullscreenchange')); + }); +} + +function setNativeFullscreenElement(el: Element | null) { + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => el, + }); +} + +describe('PreviewModal fullscreen exit', () => { + afterEach(() => { + cleanup(); + setNativeFullscreenElement(null); + }); + + it('drops the fullscreen overlay when the browser exits native fullscreen', () => { + const onClose = vi.fn(); + const { container } = render( + , + ); + + // Click the Fullscreen button. jsdom does not implement requestFullscreen + // on plain elements, so PreviewModal's fallback path runs and just sets + // the React state — exactly matching what happens after a successful + // browser fullscreen request. + const fsButton = container.querySelector( + 'button[title="Fullscreen"]', + ) as HTMLButtonElement; + expect(fsButton).toBeTruthy(); + fireEvent.click(fsButton); + const stage = container.querySelector('.ds-modal') as HTMLElement; + expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true); + + // Simulate the user pressing Esc in browser fullscreen: the browser + // exits its native fullscreen element and fires fullscreenchange, but + // (in browsers like Firefox) does not deliver the keydown to JS. + setNativeFullscreenElement(null); + dispatchFullscreenChange(); + + expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('keeps the modal mounted on Esc while fullscreen, and closes only on a second Esc', () => { + const onClose = vi.fn(); + const { container } = render( + , + ); + const fsButton = container.querySelector( + 'button[title="Fullscreen"]', + ) as HTMLButtonElement; + fireEvent.click(fsButton); + const stage = container.querySelector('.ds-modal') as HTMLElement; + expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true); + + // First Esc — drops fullscreen, must not close the modal. + fireEvent.keyDown(document, { key: 'Escape' }); + expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false); + expect(onClose).not.toHaveBeenCalled(); + + // Second Esc — closes the modal. + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('ignores fullscreenchange when another element is still fullscreen', () => { + const onClose = vi.fn(); + const { container } = render( + , + ); + const fsButton = container.querySelector( + 'button[title="Fullscreen"]', + ) as HTMLButtonElement; + fireEvent.click(fsButton); + const stage = container.querySelector('.ds-modal') as HTMLElement; + expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true); + + // Some other element is the active fullscreen target — our overlay must + // not collapse to non-fullscreen on transitions that leave a different + // element fullscreen. + const other = document.createElement('div'); + document.body.appendChild(other); + setNativeFullscreenElement(other); + dispatchFullscreenChange(); + + expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true); + document.body.removeChild(other); + }); +});