diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx index b035819d6..dcd6c5bf8 100644 --- a/client/src/pages/AtlasPage.test.tsx +++ b/client/src/pages/AtlasPage.test.tsx @@ -922,6 +922,41 @@ describe('AtlasPage', () => { }); }); + describe('FE-PAGE-ATLAS-051: bucket search results escape the panel that clips them', () => { + it('renders the result list outside the overflow-hidden desktop panel (#1899)', async () => { + server.use( + http.post('/api/maps/search', () => HttpResponse.json({ + places: [ + { name: 'Amsterdam', address: 'Amsterdam, North Holland, Netherlands', lat: 52.37, lng: 4.89 }, + { name: 'New Amsterdam Island', address: 'French Southern and Antarctic Lands, France', lat: -37.8, lng: 77.5 }, + { name: 'City of Amsterdam', address: 'North Holland, Netherlands', lat: 52.35, lng: 4.9 }, + ], + })), + ); + + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByText('Bucket List').length).toBeGreaterThan(0)); + await user.click(screen.getAllByText('Bucket List')[0]); + await waitFor(() => expect(screen.getAllByRole('button', { name: /add place/i }).length).toBeGreaterThan(0)); + await user.click(screen.getAllByRole('button', { name: /add place/i })[0]); + + const nameInput = await screen.findByPlaceholderText(/name \(country, city, place\.\.\.\)/i); + await user.type(nameInput, 'Amsterdam{Enter}'); + + // The first hit must be present — it is the one the panel used to swallow. + const firstHit = await screen.findByText('Amsterdam, North Holland, Netherlands'); + const list = firstHit.closest('button')!.parentElement as HTMLElement; + + // Portalled straight onto the body, so no ancestor's overflow can clip it. + expect(list.style.position).toBe('fixed'); + expect(list.parentElement).toBe(document.body); + expect(list.closest('.overflow-hidden')).toBeNull(); + expect(screen.getByText('City of Amsterdam')).toBeInTheDocument(); + }); + }); + describe('FE-PAGE-ATLAS-033: GeoJSON with unvisited country covers onEachFeature else branch', () => { it('loads map with visited FR and unvisited DE, covering both onEachFeature branches', async () => { const geoJsonFRandDE = { @@ -1291,7 +1326,7 @@ describe('AtlasPage', () => { () => { const els = screen.queryAllByText('Tokyo'); // Filter to those that are inside the search results dropdown (not the input itself) - const resultEl = els.find((el) => el.tagName !== 'INPUT' && el.closest('div[style*="position: absolute"]')); + const resultEl = els.find((el) => el.tagName !== 'INPUT' && el.closest('div[style*="position: fixed"]')); if (!resultEl) throw new Error('Tokyo result not found in dropdown'); return resultEl; }, @@ -1571,12 +1606,12 @@ describe('AtlasPage', () => { await user.type(nameInput, 'Paris'); fireEvent.keyDown(nameInput, { key: 'Enter' }); - // Wait for Paris result in the dropdown (absolute-positioned list) + // Wait for Paris result in the dropdown (portalled, fixed-position list) const parisBtn = await waitFor( () => { const btns = Array.from(document.querySelectorAll('button')); const btn = btns.find( - (b) => b.textContent?.includes('Paris') && b.closest('[style*="position: absolute"]'), + (b) => b.textContent?.includes('Paris') && b.closest('[style*="position: fixed"]'), ); if (!btn) throw new Error('Paris dropdown result not found'); return btn; diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index a0c1cc4b3..a1cfb6b80 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import apiClient from '../api/client' @@ -470,6 +471,7 @@ interface SidebarContentProps { function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement { const { language } = useTranslation() const statsContentRef = useRef(null) + const bucketSearchRowRef = useRef(null) const [statsWidth, setStatsWidth] = useState(undefined) useEffect(() => { const el = statsContentRef.current @@ -556,8 +558,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail {showBucketAdd ? (
{/* Search or manual name */} -
-
+
+
{ const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }} onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }} @@ -581,15 +583,36 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail )}
- {bucketSearchResults.length > 0 && ( -
+ {/* Portalled to the body: the panel that hosts this form clips its + overflow, which used to swallow the top of the list (#1899). */} + {bucketSearchResults.length > 0 && createPortal( +
{ + const rect = bucketSearchRowRef.current?.getBoundingClientRect() + if (!rect) return { left: 0, top: 0, width: 240, maxHeight: 160 } + const above = rect.top - 12 + const below = window.innerHeight - rect.bottom - 12 + const openUp = above >= below + return { + left: rect.left, + width: rect.width, + // Never taller than the room on that side, so the list scrolls + // instead of running off the edge of the viewport. + maxHeight: Math.min(160, openUp ? above : below), + ...(openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 }), + } + })(), + zIndex: 99999, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', overflowY: 'auto', + }}> {bucketSearchResults.slice(0, 6).map((r, i) => ( ))} -
+
, + document.body )}
{/* Selected place indicator */}