From 85e820dee530b857f2f72a3df30c3fdf78d9349b Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Wed, 12 Aug 2026 17:31:30 +0300 Subject: [PATCH] fix(atlas): stop the panel clipping the top of the bucket search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bucket-list place search opened its result list upward with position:absolute inside the desktop Atlas panel, which is overflow-hidden for its rounded corners. The panel leaves ~125px above the input, so the top of the 160px list was painted outside the clip box — the first result was invisible and unreachable, since that is the container's own top and not a scroll offset. Portal the list to the body and position it fixed off the input row, flipping up or down by available room and capping its height to that room. This matches CustomSelect, whose month/year dropdowns already escape the same panel this way. Fixes #1899 --- client/src/pages/AtlasPage.test.tsx | 41 ++++++++++++++++++++++++++--- client/src/pages/AtlasPage.tsx | 33 +++++++++++++++++++---- 2 files changed, 66 insertions(+), 8 deletions(-) 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 */}