diff --git a/static/js/components/shared/MediaViewer.js b/static/js/components/shared/MediaViewer.js index 2d88df88..25b97e3e 100644 --- a/static/js/components/shared/MediaViewer.js +++ b/static/js/components/shared/MediaViewer.js @@ -134,6 +134,10 @@ export function openMediaViewer(arg1, arg2, arg3) { const keyHandler = (e) => { if (e.key === 'Escape') { + // Stop propagation so bubble-phase handlers (e.g. ModalManager's + // Escape handler) do not also close the modal underneath. + e.stopPropagation(); + e.preventDefault(); closeMediaViewer(); return; } diff --git a/tests/frontend/components/MediaViewer.escape.test.js b/tests/frontend/components/MediaViewer.escape.test.js new file mode 100644 index 00000000..349201e0 --- /dev/null +++ b/tests/frontend/components/MediaViewer.escape.test.js @@ -0,0 +1,61 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname; +const MEDIA_VIEWER_MODULE = new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname; + +function setupDom() { + document.body.innerHTML = ` + + `; +} + +describe('MediaViewer Escape handling', () => { + let ModalManager; + let manager; + let openMediaViewer; + let isMediaViewerOpen; + + beforeEach(async () => { + vi.useFakeTimers(); + setupDom(); + window.scrollTo = vi.fn(); + ({ ModalManager } = await import(MODAL_MANAGER_MODULE)); + manager = new ModalManager(); + manager.initialize(); + ({ openMediaViewer, isMediaViewerOpen } = await import(MEDIA_VIEWER_MODULE)); + }); + + afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + document.body.innerHTML = ''; + vi.resetModules(); + }); + + it('closes only the media viewer, not the underlying modal, on Escape', () => { + manager.showModal('modelModal'); + expect(manager.getModal('modelModal').isOpen).toBe(true); + + openMediaViewer('https://example.com/image.png'); + expect(isMediaViewerOpen()).toBe(true); + + // Dispatch on document.body (real keydown target is the focused element, + // never document itself) so the capture handler fires before the bubble one. + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(isMediaViewerOpen()).toBe(false); + expect(manager.getModal('modelModal').isOpen).toBe(true); + }); + + it('still lets Escape close the modal when no viewer is open', () => { + manager.showModal('modelModal'); + + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(manager.getModal('modelModal').isOpen).toBe(false); + }); +});