diff --git a/client/src/components/Files/FileManagerImageLightbox.test.tsx b/client/src/components/Files/FileManagerImageLightbox.test.tsx index 6721cfc61..83b78373e 100644 --- a/client/src/components/Files/FileManagerImageLightbox.test.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.test.tsx @@ -41,8 +41,8 @@ beforeEach(() => { describe('ImageLightbox', () => { it('FE-W4LBX-001: renders nothing when the index points past the list', () => { - const { container } = render( {}} />) - expect(container).toBeEmptyDOMElement() + const { baseElement } = render( {}} />) + expect(baseElement.querySelector('[role="presentation"]')).toBeNull() }) it('FE-W4LBX-002: shows the file name and its position in the gallery', () => { @@ -53,32 +53,32 @@ describe('ImageLightbox', () => { }) it('FE-W4LBX-003: mints a signed download url for the shown image', async () => { - const { container } = render( {}} />) + const { baseElement } = render( {}} />) - await waitFor(() => expect(container.querySelector('img[alt="a.jpg"]')).not.toBeNull()) + await waitFor(() => expect(baseElement.querySelector('img[alt="a.jpg"]')).not.toBeNull()) expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download') - expect(container.querySelector('img[alt="a.jpg"]')).toHaveAttribute('src', '/f/a.jpg?token=abc') + expect(baseElement.querySelector('img[alt="a.jpg"]')).toHaveAttribute('src', '/f/a.jpg?token=abc') }) it('FE-W4LBX-004: hides the previous arrow on the first and the next arrow on the last file', () => { const first = render( {}} />) // header (3) + next arrow + 3 thumbnails - expect(first.container.querySelectorAll('.lucide-chevron-left')).toHaveLength(0) - expect(first.container.querySelectorAll('.lucide-chevron-right')).toHaveLength(1) + expect(first.baseElement.querySelectorAll('.lucide-chevron-left')).toHaveLength(0) + expect(first.baseElement.querySelectorAll('.lucide-chevron-right')).toHaveLength(1) first.unmount() const last = render( {}} />) - expect(last.container.querySelectorAll('.lucide-chevron-left')).toHaveLength(1) - expect(last.container.querySelectorAll('.lucide-chevron-right')).toHaveLength(0) + expect(last.baseElement.querySelectorAll('.lucide-chevron-left')).toHaveLength(1) + expect(last.baseElement.querySelectorAll('.lucide-chevron-right')).toHaveLength(0) }) it('FE-W4LBX-005: the arrows page through the gallery', () => { - const { container } = render( {}} />) + const { baseElement } = render( {}} />) - fireEvent.click(container.querySelector('.lucide-chevron-right')!.closest('button')!) + fireEvent.click(baseElement.querySelector('.lucide-chevron-right')!.closest('button')!) expect(screen.getByText('2 / 3')).toBeInTheDocument() - fireEvent.click(container.querySelector('.lucide-chevron-left')!.closest('button')!) + fireEvent.click(baseElement.querySelector('.lucide-chevron-left')!.closest('button')!) expect(screen.getByText('1 / 3')).toBeInTheDocument() }) @@ -109,8 +109,8 @@ describe('ImageLightbox', () => { }) it('FE-W4LBX-008: a swipe pages in the swiped direction', () => { - const { container } = render( {}} />) - const root = container.firstElementChild as HTMLElement + const { baseElement } = render( {}} />) + const root = baseElement.querySelector('[role="presentation"]') as HTMLElement fireEvent.touchStart(root, { touches: [{ clientX: 200 }] }) fireEvent.touchEnd(root, { changedTouches: [{ clientX: 100 }] }) @@ -122,8 +122,8 @@ describe('ImageLightbox', () => { }) it('FE-W4LBX-009: a short swipe is ignored', () => { - const { container } = render( {}} />) - const root = container.firstElementChild as HTMLElement + const { baseElement } = render( {}} />) + const root = baseElement.querySelector('[role="presentation"]') as HTMLElement fireEvent.touchStart(root, { touches: [{ clientX: 200 }] }) fireEvent.touchEnd(root, { changedTouches: [{ clientX: 180 }] }) @@ -132,9 +132,9 @@ describe('ImageLightbox', () => { }) it('FE-W4LBX-010: a touch end without a start is ignored', () => { - const { container } = render( {}} />) + const { baseElement } = render( {}} />) - fireEvent.touchEnd(container.firstElementChild!, { changedTouches: [{ clientX: 0 }] }) + fireEvent.touchEnd(baseElement.querySelector('[role="presentation"]')!, { changedTouches: [{ clientX: 0 }] }) expect(screen.getByText('2 / 3')).toBeInTheDocument() }) @@ -155,13 +155,13 @@ describe('ImageLightbox', () => { it('FE-W4LBX-012: clicking the backdrop closes but clicking the image does not', async () => { const onClose = vi.fn() - const { container } = render() + const { baseElement } = render() - await waitFor(() => expect(container.querySelector('img[alt="a.jpg"]')).not.toBeNull()) - fireEvent.click(container.querySelector('img[alt="a.jpg"]')!) + await waitFor(() => expect(baseElement.querySelector('img[alt="a.jpg"]')).not.toBeNull()) + fireEvent.click(baseElement.querySelector('img[alt="a.jpg"]')!) expect(onClose).not.toHaveBeenCalled() - fireEvent.click(container.firstElementChild!) + fireEvent.click(baseElement.querySelector('[role="presentation"]')!) expect(onClose).toHaveBeenCalledOnce() }) @@ -184,21 +184,21 @@ describe('ImageLightbox', () => { it('FE-W4LBX-015: a video plays in the player and never mints a download token', async () => { const video = file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' }) - const { container } = render( {}} />) + const { baseElement } = render( {}} />) // The player loads on demand now — plyr no longer ships with the file manager. expect(await screen.findByTestId('video')).toHaveAttribute('data-src', '/f/clip.mp4') expect(getAuthUrl).not.toHaveBeenCalled() - expect(container.querySelector('img')).toBeNull() + expect(baseElement.querySelector('img')).toBeNull() }) it('FE-W4LBX-016: a video thumbnail is a play glyph, not an image request', async () => { const files = [IMAGES[0], file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' })] - const { container } = render( {}} />) + const { baseElement } = render( {}} />) await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download')) expect(getAuthUrl).not.toHaveBeenCalledWith('/f/clip.mp4', 'download') - expect(container.querySelector('.lucide-play')).not.toBeNull() + expect(baseElement.querySelector('.lucide-play')).not.toBeNull() }) it('FE-W4LBX-017: clicking the video wrapper does not close the lightbox', () => { @@ -225,7 +225,7 @@ describe('ImageLightbox', () => { it('FE-W4LBX-020: a mint that lands after the gallery moved on does not paint', async () => { const pending: Record void> = {} getAuthUrl.mockImplementation((url: string) => new Promise(resolve => { pending[url] = resolve })) - const { container } = render( {}} />) + const { baseElement } = render( {}} />) await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download')) fireEvent.keyDown(window, { key: 'ArrowRight' }) @@ -236,7 +236,7 @@ describe('ImageLightbox', () => { pending['/f/a.jpg']('/f/a.jpg?token=a') }) - expect(container.querySelector('img[alt="b.jpg"]')).toHaveAttribute('src', '/f/b.jpg?token=b') + expect(baseElement.querySelector('img[alt="b.jpg"]')).toHaveAttribute('src', '/f/b.jpg?token=b') }) it('FE-W4LBX-019: the strip mints thumbnail tokens only for thumbs in view', async () => { diff --git a/client/src/components/Files/FileManagerImageLightbox.tsx b/client/src/components/Files/FileManagerImageLightbox.tsx index cb16d6ae6..2361370e8 100644 --- a/client/src/components/Files/FileManagerImageLightbox.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react' import { useTranslation } from '../../i18n' import type { TripFile } from '../../types' @@ -66,7 +67,10 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro ) : null - return ( + // A portal, as the two document previews are. Rendered in place, the overlay + // sits inside the trip page's stacking context, below the navbar's z-[200], + // which then covered the header and its buttons. + return createPortal(
)} -
+ , + document.body, ) }