mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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