Merge pull request #1913 from liketrek/fix/atlas-bucket-search-dropdown-clipped

fix(atlas): stop the panel clipping the top of the bucket search results
This commit is contained in:
Konstantinos Thermos
2026-08-12 18:53:35 +03:00
committed by GitHub
2 changed files with 66 additions and 8 deletions
+38 -3
View File
@@ -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(<AtlasPage />);
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;
+28 -5
View File
@@ -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<HTMLDivElement>(null)
const bucketSearchRowRef = useRef<HTMLDivElement>(null)
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
useEffect(() => {
const el = statsContentRef.current
@@ -556,8 +558,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{showBucketAdd ? (
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Search or manual name */}
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', gap: 4 }}>
<div>
<div ref={bucketSearchRowRef} style={{ display: 'flex', gap: 4 }}>
<input type="text" value={bucketForm.name || bucketSearch}
onChange={e => { 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
</button>
)}
</div>
{bucketSearchResults.length > 0 && (
<div className="bg-surface-card border border-edge" style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
{/* 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(
<div className="bg-surface-card border border-edge" style={{
position: 'fixed',
...(() => {
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) => (
<button key={i} onClick={() => onSelectBucketPoi(r)} className="border-b border-edge-faint" style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', borderTop: 'none', borderLeft: 'none', borderRight: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
<span className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{r.name}</span>
{r.address && <span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>{r.address}</span>}
</button>
))}
</div>
</div>,
document.body
)}
</div>
{/* Selected place indicator */}