From 620f4c6ecd7c662dc51d60a640876b8263f844dd Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 4 Sep 2026 23:56:35 +0200 Subject: [PATCH] fix(roadtrip): let the section filter shorten instead of overflowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Roadtrip/RoadtripCorridorPanel.tsx | 16 ++++--- .../components/shared/CustomSelect.test.tsx | 41 +++++++++++++++++ client/src/components/shared/CustomSelect.tsx | 45 +++++++++++++++++-- 3 files changed, 94 insertions(+), 8 deletions(-) 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({