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({