import React, { useState, useRef, useEffect, useId } from 'react' import { createPortal } from 'react-dom' import { ChevronDown, Check } from 'lucide-react' import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition' import { useTranslation } from '../../i18n' interface SelectOption { // Callers use both string keys and numeric ids (e.g. day/place ids) as values, and // the value they hold need not be of the same kind as the options: see sameValue. value: string | number label: string icon?: React.ReactNode isHeader?: boolean searchLabel?: string groupLabel?: string badge?: string } interface CustomSelectProps { ariaLabel?: string 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' /** The trigger's id, so a label outside can point at it. */ id?: string } /** * Whether an option is the one the caller holds. * * onChange hands back the option's own value, and callers routinely store it in another * form: the trip share dialog kept String(value) against options keyed by numeric user * ids, so a strict comparison never found the pick and the trigger stayed on its * placeholder while the invite button already knew who was chosen (#2478). An id reads * the same as a number or as text, so both sides are compared as text. A caller holding * nothing (null or undefined at runtime) matches no option. */ function sameValue(held: string | number | null | undefined, option: string | number): boolean { return held != null && String(held) === String(option) } /** 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({ ariaLabel, value, onChange, options = [], placeholder = '', searchable = false, style = {}, size = 'md', disabled = false, menuFit = 'anchor', id, }: CustomSelectProps) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const ref = useRef(null) const triggerRef = useRef(null) const dropRef = useRef(null) const searchRef = useRef(null) const keyboardDirection = useRef<'first' | 'last' | null>(null) const menuId = useId() const { t } = useTranslation() // 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 if (!keyboardDirection.current) 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(() => { if (!open || !keyboardDirection.current) return const buttons = dropRef.current?.querySelectorAll('button') const direction = keyboardDirection.current keyboardDirection.current = null if (buttons?.length) buttons[direction === 'last' ? buttons.length - 1 : 0].focus() }, [open]) 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 => sameValue(value, o.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' const closeMenu = () => { setOpen(false) setSearch('') triggerRef.current?.focus() } const handleKeyDown = (event: React.KeyboardEvent) => { if (disabled) return if (event.key === 'Escape' && open) { event.preventDefault() event.stopPropagation() closeMenu() } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() const backwards = event.key === 'ArrowUp' if (!open) { keyboardDirection.current = backwards ? 'last' : 'first' setSearch('') setOpen(true) } else { const buttons = Array.from(dropRef.current?.querySelectorAll('button') ?? []) const current = buttons.findIndex(button => button === document.activeElement) const next = current < 0 ? (backwards ? buttons.length - 1 : 0) : (current + (backwards ? -1 : 1) + buttons.length) % buttons.length buttons[next]?.focus() } } } return (
{/* Trigger */} {/* Dropdown */} {open && createPortal(
{/* Search */} {searchable && (
setSearch(e.target.value)} placeholder={t('common.search')} aria-label={t('common.search')} 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 = sameValue(value, option.value) return ( ) }) )}
, document.body )}
) }