refactor(atlas): draw the country and place search rows with one row component

The country rows and the geocoded place rows repeated the same button,
hover tint and trailing chevron. A ResultRow inside AtlasCountrySearch
now draws that frame and each list passes its own leading content.
This commit is contained in:
Maurice
2026-10-09 15:01:54 +02:00
parent 71b7b209dc
commit 9f1d5c4a63
2 changed files with 97 additions and 42 deletions
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import AtlasCountrySearch from './AtlasCountrySearch';
import type { AtlasPlaceHit } from './atlasModel';
const PLACE: AtlasPlaceHit = { name: 'Lisbon', address: 'Portugal', lat: 38.72, lng: -9.14 };
function renderSearch(overrides: Partial<Parameters<typeof AtlasCountrySearch>[0]> = {}) {
const props = {
dark: false,
t: (key: string) => key,
search: 'po',
setSearch: vi.fn(),
results: [{ code: 'PT', label: 'Portugal' }],
setResults: vi.fn(),
open: true,
setOpen: vi.fn(),
options: [],
onSelect: vi.fn(),
placeResults: [PLACE],
placesLoading: false,
onQueryChange: vi.fn(),
onSelectPlace: vi.fn(),
...overrides,
};
return { props, ...render(<AtlasCountrySearch {...props} />) };
}
describe('AtlasCountrySearch result rows', () => {
it('draws a country and a place row the same way, each ending in a chevron', () => {
renderSearch();
const country = screen
.getByText('Portugal', { selector: 'span.text-content' })
.closest('button') as HTMLButtonElement;
const place = screen.getByText('Lisbon').closest('button') as HTMLButtonElement;
for (const row of [country, place]) {
expect(row.style.padding).toBe('10px 12px');
expect(row.style.borderBottom).toBe('1px solid rgba(0, 0, 0, 0.06)');
expect(row.lastElementChild?.tagName.toLowerCase()).toBe('svg');
}
expect(country.querySelector('img')).toHaveAttribute('src', 'https://flagcdn.com/w40/pt.png');
expect(place).toHaveTextContent('Portugal');
});
it('selects the country or the place that was clicked', () => {
const { props } = renderSearch();
fireEvent.click(screen.getByAltText('PT').closest('button') as HTMLButtonElement);
expect(props.onSelect).toHaveBeenCalledWith('PT');
fireEvent.click(screen.getByText('Lisbon').closest('button') as HTMLButtonElement);
expect(props.onSelectPlace).toHaveBeenCalledWith(PLACE);
});
it('tints a row on hover in the colour of the scheme and clears it on leave', () => {
renderSearch({ dark: true });
const row = screen.getByText('Lisbon').closest('button') as HTMLButtonElement;
expect(row.style.borderBottom).toBe('1px solid rgba(255, 255, 255, 0.06)');
fireEvent.mouseEnter(row);
expect(row.style.background).toBe('rgba(255, 255, 255, 0.06)');
fireEvent.mouseLeave(row);
expect(row.style.background).toBe('transparent');
});
});
+35 -42
View File
@@ -142,33 +142,14 @@ export default function AtlasCountrySearch({
}}
>
{results.map((r) => (
<button type="button"
key={r.code}
onClick={() => onSelect(r.code)}
style={{
width: '100%',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'inherit',
textAlign: 'start',
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<ResultRow key={r.code} dark={dark} onClick={() => onSelect(r.code)}>
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<img src={`https://flagcdn.com/w40/${r.code.toLowerCase()}.png`} alt={r.code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover' }} />
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 650, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.label}
</span>
</span>
<ChevronRight size={16} className="text-content-faint" style={{ flexShrink: 0 }} />
</button>
</ResultRow>
))}
{(placeResults.length > 0 || placesLoading) && (
@@ -187,25 +168,7 @@ export default function AtlasCountrySearch({
</div>
)}
{placeResults.map((p) => (
<button type="button"
key={`${p.lat},${p.lng},${p.name}`}
onClick={() => onSelectPlace(p)}
style={{
width: '100%',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'inherit',
textAlign: 'start',
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<ResultRow key={`${p.lat},${p.lng},${p.name}`} dark={dark} onClick={() => onSelectPlace(p)}>
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span
style={{
@@ -227,8 +190,7 @@ export default function AtlasCountrySearch({
)}
</span>
</span>
<ChevronRight size={16} className="text-content-faint" style={{ flexShrink: 0 }} />
</button>
</ResultRow>
))}
</div>
)}
@@ -236,3 +198,34 @@ export default function AtlasCountrySearch({
</div>
)
}
/** One row of the dropdown, a country or a geocoded place, with the chevron at its end. */
function ResultRow({ dark, onClick, children }: {
dark: boolean
onClick: () => void
children: React.ReactNode
}): React.ReactElement {
return (
<button type="button"
onClick={onClick}
style={{
width: '100%',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'inherit',
textAlign: 'start',
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{children}
<ChevronRight size={16} className="text-content-faint" style={{ flexShrink: 0 }} />
</button>
)
}