mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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:
@@ -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 ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<CustomSelect
|
||||
@@ -430,8 +435,9 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
style={{ flex: 1 }}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
size="sm"
|
||||
menuFit="content"
|
||||
/>
|
||||
{corridor.section ? (
|
||||
<CustomSelect
|
||||
@@ -441,7 +447,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
value: String(km),
|
||||
label: `± ${formatDistance(km, distanceUnit)}`,
|
||||
}))}
|
||||
style={{ width: 110 }}
|
||||
style={{ width: 110, flexShrink: 0 }}
|
||||
size="sm"
|
||||
/>
|
||||
) : 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"
|
||||
/>
|
||||
<CustomSelect
|
||||
@@ -471,7 +477,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
{ value: '0', label: t('roadtrip.poi.anyPower') },
|
||||
...KW_STEPS.map(kw => ({ value: String(kw), label: `${kw}+ kW` })),
|
||||
]}
|
||||
style={{ flex: 1 }}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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" />);
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user