mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user