fix(files): the image lightbox renders through a portal

Rendered in place, its fixed overlay sat inside the trip page's stacking
context, below the navbar's z-[200]: the bar painted over the lightbox's
header, so Open in new tab, Download and the x were unreachable. The two
document previews beside it already portal into the body for this reason.

The tests read the lightbox through baseElement now, since a portal puts
it outside the container Testing Library returns.
This commit is contained in:
Maurice
2026-09-22 15:36:49 +02:00
parent e8637f7208
commit 0f28c929be
2 changed files with 35 additions and 30 deletions
@@ -41,8 +41,8 @@ beforeEach(() => {
describe('ImageLightbox', () => {
it('FE-W4LBX-001: renders nothing when the index points past the list', () => {
const { container } = render(<ImageLightbox files={[]} initialIndex={0} onClose={() => {}} />)
expect(container).toBeEmptyDOMElement()
const { baseElement } = render(<ImageLightbox files={[]} initialIndex={0} onClose={() => {}} />)
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(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
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(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
// 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(<ImageLightbox files={IMAGES} initialIndex={2} onClose={() => {}} />)
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(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
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(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
const root = container.firstElementChild as HTMLElement
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
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(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
const root = container.firstElementChild as HTMLElement
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
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(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
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(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
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(<ImageLightbox files={[video]} initialIndex={0} onClose={() => {}} />)
const { baseElement } = render(<ImageLightbox files={[video]} initialIndex={0} onClose={() => {}} />)
// 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(<ImageLightbox files={files} initialIndex={0} onClose={() => {}} />)
const { baseElement } = render(<ImageLightbox files={files} initialIndex={0} onClose={() => {}} />)
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<string, (url: string) => void> = {}
getAuthUrl.mockImplementation((url: string) => new Promise<string>(resolve => { pending[url] = resolve }))
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
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 () => {
@@ -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
</button>
) : 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(
<div
// Backdrop only — Escape and the header's close button do the same job for
// the keyboard. Closing on the backdrop's own clicks (rather than letting
@@ -130,7 +134,8 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
))}
</div>
)}
</div>
</div>,
document.body,
)
}