mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
fix(atlas): stop the panel clipping the top of the bucket search results
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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user