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(null) const dropRef = useRef(null) const searchRef = useRef(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 (
{/* Trigger */} {/* Dropdown */} {open && createPortal(
{/* Search */} {searchable && (
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', }} />
)} {/* Options — capped at whatever the viewport still shows, so a list opened beside an on-screen keyboard shrinks instead of hiding under it (#2000). */}
{filtered.length === 0 ? (
—
) : ( filtered.map(option => { if (option.isHeader) { return (
{option.label}
) } const isSelected = option.value === value return ( ) }) )}
, document.body )}
) }