fix(roadtrip): let the section filter shorten instead of overflowing

A nowrap label reports its whole width as its min-content contribution, so the
place-name select sat at its default `min-width: auto` and refused to shrink —
pushing the ± kilometres control past the panel's right edge instead of
ellipsing the name.

The menu that select opens now grows to its options rather than inheriting the
narrowed trigger, bounded by the panel it sits in so it never hangs over the
map: a list of identical shortened prefixes is not a list you can pick from.
This commit is contained in:
Maurice
2026-09-04 23:56:35 +02:00
committed by Maurice
parent 36ac5de84c
commit 620f4c6ecd
3 changed files with 94 additions and 8 deletions
@@ -104,6 +104,47 @@ describe('CustomSelect', () => {
expect(Number.parseInt(list.style.maxHeight, 10)).toBeGreaterThan(0);
});
// A select squeezed into a narrow flex row still has to offer readable options: the
// trigger shortens to share its row with the control beside it, the menu does not.
it('FE-COMP-SELECT-011: the menu is as wide as its trigger by default', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} />);
await user.click(screen.getByRole('button'));
const panel = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
expect(panel.style.width).not.toBe('max-content');
expect(panel.style.maxWidth).toBe('');
});
it('FE-COMP-SELECT-012: menuFit content grows the menu to its options, bounded twice', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} menuFit="content" />);
await user.click(screen.getByRole('button'));
const panel = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
expect(panel.style.width).toBe('max-content');
// Never narrower than the trigger, never wider than what the window still has.
expect(panel.style.minWidth).not.toBe('');
expect(Number.parseInt(panel.style.maxWidth, 10)).toBeGreaterThan(0);
});
it('FE-COMP-SELECT-013: a content-fitted menu stops at the edge of its panel', async () => {
const user = userEvent.setup();
render(
<div data-testid="panel">
<CustomSelect value="" onChange={onChange} options={OPTIONS} menuFit="content" />
</div>,
);
// jsdom measures everything as zero, so the panel says how wide it is.
const panel = screen.getByTestId('panel');
panel.getBoundingClientRect = () => ({ left: 0, right: 180, width: 180, top: 0, bottom: 32, height: 32, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
await user.click(screen.getByRole('button'));
const menu = screen.getByText('Apple').closest('div[style*="position: fixed"]') as HTMLElement;
expect(menu.style.maxWidth).toBe('180px');
});
it('FE-COMP-SELECT-008: disabled state prevents the dropdown from opening', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} disabled={true} placeholder="Pick" />);
+42 -3
View File
@@ -24,6 +24,33 @@ interface CustomSelectProps {
style?: React.CSSProperties
size?: 'sm' | 'md'
disabled?: boolean
/**
* How wide the open menu is.
*
* 'anchor' (the default) keeps it exactly as wide as the trigger, which is what a
* select normally wants. 'content' lets it grow to its longest option instead, for the
* cases where the trigger is a narrow flex item and the options are place names: there
* the trigger has to shorten to share its row, and a menu that shortened with it would
* offer a list of identical prefixes.
*/
menuFit?: 'anchor' | 'content'
}
/** How wide a content-fitted menu may get before it stops reading as a menu. */
const MENU_MAX_WIDTH = 420
/**
* How much room a content-fitted menu has, measured from the trigger's left edge.
*
* Bounded by the trigger's own container first: a select sits inside a panel, and a menu
* that grew past that panel's edge would hang over whatever is beside it — here, the map.
* The window is the second bound, for a container that reaches the edge of the screen.
*/
function menuRoom(anchor: HTMLElement | null, left: number): number {
const toWindowEdge = typeof window === 'undefined' ? MENU_MAX_WIDTH : window.innerWidth - left - 8
const box = anchor?.parentElement?.getBoundingClientRect()
const toPanelEdge = box && box.width > 0 ? box.right - left : Number.POSITIVE_INFINITY
return Math.min(MENU_MAX_WIDTH, toWindowEdge, toPanelEdge)
}
export default function CustomSelect({
@@ -35,6 +62,7 @@ export default function CustomSelect({
style = {},
size = 'md',
disabled = false,
menuFit = 'anchor',
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
@@ -131,9 +159,20 @@ export default function CustomSelect({
<div ref={dropRef} style={{
position: 'fixed',
...(anchored
? anchored.flipped
? { bottom: anchored.bottom, left: anchored.left, width: anchored.width }
: { top: anchored.top, left: anchored.left, width: anchored.width }
? {
...(anchored.flipped ? { bottom: anchored.bottom } : { top: anchored.top }),
left: anchored.left,
// Never narrower than the trigger, and never past the right-hand edge of
// the window. The cap is read at render time rather than measured, and
// that is enough: every reason the anchor moved already re-renders this.
...(menuFit === 'content'
? {
minWidth: anchored.width,
width: 'max-content',
maxWidth: Math.max(anchored.width, menuRoom(ref.current, anchored.left)),
}
: { width: anchored.width }),
}
: { top: 0, left: 0, width: 200 }),
zIndex: 99999,
background: 'var(--bg-card)',