open-design/e2e/tests/preview-modal-fullscreen.test.tsx
d 🔹 f8af2cd875
fix(web): exit PreviewModal fullscreen on first Esc press (#168)
Closes #141.

When the user clicked the Fullscreen button, requestFullscreen() put the
stage element into native browser fullscreen and React's `fullscreen`
state was set true. Pressing Esc was meant to exit the overlay, but in
browsers like Firefox the browser consumes Esc to drop its native
fullscreen element without delivering keydown to JS. The React state
stayed true, the `ds-modal-fullscreen` class lingered, and only a second
Esc reached the keydown handler that flipped the state.

Subscribe to `fullscreenchange` so the React state mirrors the native
state. When the browser exits its fullscreen element, the overlay drops
on the same keystroke. The keydown handler is still needed for the
fallback path (no native fullscreen API support, where requestFullscreen
is undefined and only React state is set).

Adds three regression tests in e2e/tests/preview-modal-fullscreen.test.tsx
covering the bug fix path, the keydown fallback, and a non-collapse
guard for transitions where another element is still fullscreen.

Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
2026-04-30 23:35:01 +08:00

109 lines
4.1 KiB
TypeScript

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: '<p>hi</p>' }],
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(
<PreviewModal {...baseProps} onClose={onClose} />,
);
// 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(
<PreviewModal {...baseProps} onClose={onClose} />,
);
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(
<PreviewModal {...baseProps} onClose={onClose} />,
);
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);
});
});