diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index b67bcf9aa..f950d4955 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -403,7 +403,12 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro decide roughly where to stop and then look at what is there, rather than reading seventy hits spread over seven hundred kilometres. The midpoint of a leg is offered alongside the stops, because a break on a five-hour drive - belongs in the middle of it and not at either end. */} + belongs in the middle of it and not at either end. + + The minWidth: 0 is what makes the name shorten instead of pushing the + kilometre control off the panel: a nowrap label reports its whole width as + its min-content contribution, so a flex item at its default min-width of + auto refuses to go below it however long the place name is. */} {corridor.anchors.length > 1 && corridor.day ? (
{corridor.section ? ( ) : null} @@ -461,7 +467,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro { value: '', label: t('roadtrip.poi.anySocket') }, ...socketsFound.map(s => ({ value: s, label: SOCKET_LABEL[s] ?? s })), ]} - style={{ flex: 1 }} + style={{ flex: 1, minWidth: 0 }} size="sm" /> ({ value: String(kw), label: `${kw}+ kW` })), ]} - style={{ flex: 1 }} + style={{ flex: 1, minWidth: 0 }} size="sm" />
diff --git a/client/src/components/shared/CustomSelect.test.tsx b/client/src/components/shared/CustomSelect.test.tsx index 216093b4d..2f13fc5b6 100644 --- a/client/src/components/shared/CustomSelect.test.tsx +++ b/client/src/components/shared/CustomSelect.test.tsx @@ -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(); + 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(); + 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( +
+ +
, + ); + // 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(); diff --git a/client/src/components/shared/CustomSelect.tsx b/client/src/components/shared/CustomSelect.tsx index 16e35c34d..6c02c27d1 100644 --- a/client/src/components/shared/CustomSelect.tsx +++ b/client/src/components/shared/CustomSelect.tsx @@ -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({