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) => (
))}
-