// src/components/ui/MultiSelect.jsx // Multi-select dropdown — same visual language as Select.jsx (floating portal // menu, .select-* trigger styling) but with checkbox rows so multiple options // can be chosen at once. Trigger shows "All", a single label, or "N selected". // // Props: // options — Array<{ value: string, label: ReactNode }> // value — Array — currently selected values (empty = "all") // onChange — (nextValues: string[]) => void // placeholder — shown when value is empty (default: 'All') // disabled — boolean // className — extra classes on the trigger button // id — forwarded for label association import { useState, useRef, useEffect } from 'react' import { createPortal } from 'react-dom' import { computeFloatingMenuPosition } from '@/lib/floatingMenuPosition' export default function MultiSelect({ options = [], value = [], onChange, placeholder = 'All', disabled = false, className = '', id, ...props }) { const [open, setOpen] = useState(false) const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0, maxHeight: 280 }) const triggerRef = useRef(null) const menuRef = useRef(null) useEffect(() => { if (!open) return function close(e) { if (triggerRef.current?.contains(e.target)) return if (menuRef.current?.contains(e.target)) return setOpen(false) } function closeOnScroll(e) { if (menuRef.current?.contains(e.target)) return if (triggerRef.current?.contains(e.target)) return setOpen(false) } document.addEventListener('mousedown', close) document.addEventListener('scroll', closeOnScroll, true) return () => { document.removeEventListener('mousedown', close) document.removeEventListener('scroll', closeOnScroll, true) } }, [open]) function openMenu() { if (disabled) return if (triggerRef.current) { setMenuPos(computeFloatingMenuPosition(triggerRef.current.getBoundingClientRect())) } setOpen(true) } function handleToggle() { if (open) setOpen(false) else openMenu() } function toggleValue(v) { const next = value.includes(v) ? value.filter((x) => x !== v) : [...value, v] onChange?.(next) } const label = value.length === 0 ? placeholder : value.length === 1 ? (options.find((o) => o.value === value[0])?.label ?? value[0]) : `${value.length} selected` const menu = open && createPortal(
{value.length > 0 && (
{ e.preventDefault(); onChange?.([]) }} > Clear selection
)} {options.map((opt) => { const checked = value.includes(opt.value) return (
{ e.preventDefault(); toggleValue(opt.value) }} > {opt.label}
) })}
, document.body ) return ( <> {menu} ) }