import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; import { createPortal } from "react-dom"; export type CustomSelectOption = { value: string; label: string; }; type CustomSelectProps = { options: CustomSelectOption[]; value: string; onChange: (value: string) => void; className?: string; disabled?: boolean; ariaLabel?: string; }; const MENU_MAX_HEIGHT = 240; const MENU_GAP = 8; /** * Plain custom (non-native) dropdown — a trigger button plus a portal-rendered * listbox with viewport-aware positioning and outside-click close. Mirrors the * pattern used by BgmTrackDropdown / ScenePositionDropdown so selects render * consistently inside scrollable modals. */ export function CustomSelect({ options, value, onChange, className = "", disabled = false, ariaLabel, }: CustomSelectProps) { const [open, setOpen] = useState(false); const triggerRef = useRef(null); const menuRef = useRef(null); const [menuStyle, setMenuStyle] = useState({}); const selected = options.find((o) => o.value === value); const label = selected?.label ?? value; const updateMenuPosition = useCallback(() => { const trigger = triggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom - MENU_GAP; const spaceAbove = rect.top - MENU_GAP; const openUp = spaceBelow < MENU_MAX_HEIGHT && spaceAbove > spaceBelow; const margin = 8; const width = rect.width; const left = Math.min(Math.max(margin, rect.left), window.innerWidth - width - margin); const style: CSSProperties = { position: "fixed", left, width, zIndex: 9999, maxHeight: MENU_MAX_HEIGHT, }; if (openUp) { style.bottom = window.innerHeight - rect.top + MENU_GAP; } else { style.top = rect.bottom + MENU_GAP; } setMenuStyle(style); }, []); useLayoutEffect(() => { if (!open) return; updateMenuPosition(); const onReposition = () => updateMenuPosition(); window.addEventListener("resize", onReposition); window.addEventListener("scroll", onReposition, true); return () => { window.removeEventListener("resize", onReposition); window.removeEventListener("scroll", onReposition, true); }; }, [open, updateMenuPosition]); useEffect(() => { if (!open) return; const onClickOutside = (evt: MouseEvent) => { const target = evt.target as Node; if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return; setOpen(false); }; document.addEventListener("mousedown", onClickOutside); return () => document.removeEventListener("mousedown", onClickOutside); }, [open]); const pick = (next: string) => { onChange(next); setOpen(false); }; return (
{open && createPortal(
{options.map((o) => ( ))}
, document.body, )}
); }