Files
TREK/client/src/components/shared/CustomSelect.tsx
T
Maurice 620f4c6ecd 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.
2026-09-07 18:36:54 +02:00

273 lines
12 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition'
interface SelectOption {
// Callers use both string keys and numeric ids (e.g. day/place ids) as values;
// the component only does strict-equality lookups and key rendering, so either works.
value: string | number
label: string
icon?: React.ReactNode
isHeader?: boolean
searchLabel?: string
groupLabel?: string
badge?: string
}
interface CustomSelectProps {
value: string | number
onChange: (value: string | number) => void
options?: SelectOption[]
placeholder?: string
searchable?: boolean
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({
value,
onChange,
options = [],
placeholder = '',
searchable = false,
style = {},
size = 'md',
disabled = false,
menuFit = 'anchor',
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
// Follows the trigger while the sheet scrolls and while the on-screen keyboard
// resizes the viewport, instead of freezing at the rect measured on open (#1999).
const anchored = useAnchoredPosition(ref, open)
useEffect(() => {
if (!open || !searchable || !searchRef.current) return
searchRef.current.focus()
// Focusing raises the keyboard on a phone; scroll the trigger up so the list
// it just opened is not left underneath it (#2000).
scrollAnchorIntoView(ref.current)
}, [open, searchable])
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const selected = options.find(o => o.value === value)
const filtered = searchable && search
? (() => {
const q = search.toLowerCase()
const result: SelectOption[] = []
let currentHeader: SelectOption | null = null
let headerAdded = false
for (const o of options) {
if (o.isHeader) {
currentHeader = o
headerAdded = false
continue
}
const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
if (haystack.includes(q)) {
if (currentHeader && !headerAdded) {
result.push(currentHeader)
headerAdded = true
}
result.push(o)
}
}
return result
})()
: options
const sm = size === 'sm'
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
{/* Trigger */}
<button
type="button"
disabled={disabled}
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: 'var(--text-primary)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit',
cursor: disabled ? 'default' : 'pointer', outline: 'none', textAlign: 'left',
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
opacity: disabled ? 0.5 : 1,
}}
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--text-faint)' }}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{selected ? selected.label : placeholder}
</span>
{selected?.badge && (
<span style={{
flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)',
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
letterSpacing: '0.01em',
}}>{selected.badge}</span>
)}
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
{/* Dropdown */}
{open && createPortal(
<div ref={dropRef} style={{
position: 'fixed',
...(anchored
? {
...(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)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid var(--border-primary)',
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
overflow: 'hidden',
animation: 'trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1)',
transformOrigin: anchored?.flipped ? 'bottom center' : 'top center',
willChange: 'transform, opacity',
}}>
{/* Search */}
{searchable && (
<div style={{ padding: '6px 6px 2px' }}>
<input
ref={searchRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="..."
style={{
width: '100%', border: '1px solid var(--border-secondary)', borderRadius: 6,
padding: '5px 8px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
boxSizing: 'border-box',
}}
/>
</div>
)}
{/* Options — capped at whatever the viewport still shows, so a list opened
beside an on-screen keyboard shrinks instead of hiding under it (#2000). */}
<div style={{
maxHeight: Math.min(220, Math.max(96, (anchored?.maxHeight ?? 220) - (searchable ? 38 : 0))),
overflowY: 'auto',
// The panel is portaled to document.body and positioned fixed, so its
// scroll chain runs to the viewport rather than to the sheet it looks
// like it belongs to. On a phone that meant a flick past either end of
// the list moved the page instead (#2078).
overscrollBehavior: 'contain',
padding: '4px',
}}>
{filtered.length === 0 ? (
<div style={{ padding: '10px 12px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
) : (
filtered.map(option => {
if (option.isHeader) {
return (
<div key={option.value} style={{
padding: '5px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)',
textTransform: 'uppercase', letterSpacing: '0.03em',
background: 'var(--bg-tertiary)', borderRadius: 4, margin: '2px 0',
}}>
{option.label}
</div>
)
}
const isSelected = option.value === value
return (
<button
key={option.value}
type="button"
onClick={() => { onChange(option.value); setOpen(false); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 10px', borderRadius: 6,
border: 'none', background: isSelected ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit',
cursor: 'pointer', textAlign: 'left', transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isSelected ? 'var(--bg-hover)' : 'transparent'}
>
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
{option.badge && (
<span style={{
flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)',
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
letterSpacing: '0.01em',
}}>{option.badge}</span>
)}
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
</button>
)
})
)}
</div>
</div>,
document.body
)}
</div>
)
}