import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react' import { createPortal } from 'react-dom' /** * Nova NovaSelect – Select2-style dropdown * * Options format: [{ value, label, icon?, disabled?, group? }] * * @prop {Array} options - list of option objects * @prop {*} value - selected value (or array of values in multi mode) * @prop {function} onChange - called with new value (or array in multi mode) * @prop {boolean} multi - allow multiple selections * @prop {string} placeholder - placeholder text * @prop {boolean} searchable - filter options by typing (default true) * @prop {boolean} clearable - show clear button when a value is selected * @prop {string} label - label above the trigger * @prop {string} error - validation error * @prop {string} hint - helper text * @prop {boolean} required - asterisk on label * @prop {boolean} disabled * @prop {function} renderOption - custom render fn: (option) => ReactNode */ export default function NovaSelect({ options = [], value, onChange, multi = false, placeholder = 'Select…', searchable = true, clearable = false, label, error, hint, required = false, disabled = false, renderOption, id, className = '', }) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [highlighted, setHigh] = useState(-1) const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 300, openUp: false }) const triggerRef = useRef(null) const searchRef = useRef(null) const listRef = useRef(null) const inputId = id ?? (label ? `nova-select-${label.toLowerCase().replace(/\s+/g, '-')}` : 'nova-select') // Normalize value to array internally const selected = useMemo(() => { if (multi) return Array.isArray(value) ? value : (value != null ? [value] : []) return value != null ? [value] : [] }, [value, multi]) const selectedSet = useMemo(() => new Set(selected.map(String)), [selected]) // Filtered + grouped options const filtered = useMemo(() => { const q = search.toLowerCase() return options.filter((o) => !q || o.label.toLowerCase().includes(q)) }, [options, search]) // Compute dropdown position from trigger bounding rect const measurePosition = useCallback(() => { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() const spaceBelow = window.innerHeight - rect.bottom const spaceAbove = rect.top const dropH = Math.min(280, filtered.length * 38 + 52) // approx const openUp = spaceBelow < dropH + 8 && spaceAbove > spaceBelow // Clamp horizontal position so the dropdown doesn't render off-screen or far away const padding = 8 const leftClamped = Math.max(padding, Math.min(rect.left, window.innerWidth - rect.width - padding)) setDropPos({ top: openUp ? rect.top - dropH - 4 : rect.bottom + 4, left: leftClamped, width: rect.width, openUp, }) }, [filtered.length]) const openDropdown = useCallback(() => { if (disabled) return measurePosition() setOpen(true) setHigh(-1) }, [disabled, measurePosition]) const closeDropdown = useCallback(() => { setOpen(false) setSearch('') setHigh(-1) }, []) // Focus search when opened useLayoutEffect(() => { if (open && searchable) { setTimeout(() => searchRef.current?.focus(), 0) } }, [open, searchable]) // Close dropdown when scrolling outside it (prevents portal drifting from trigger) useEffect(() => { if (!open) return const onScroll = (e) => { const dropdown = document.getElementById(`nova-select-dropdown-${inputId}`) if (dropdown && dropdown.contains(e.target)) return // scrolling inside list — keep open closeDropdown() } const onResize = () => closeDropdown() window.addEventListener('scroll', onScroll, true) window.addEventListener('resize', onResize) return () => { window.removeEventListener('scroll', onScroll, true) window.removeEventListener('resize', onResize) } }, [open, closeDropdown, inputId]) // Click outside useEffect(() => { if (!open) return const handler = (e) => { if ( !triggerRef.current?.contains(e.target) && !document.getElementById(`nova-select-dropdown-${inputId}`)?.contains(e.target) ) { closeDropdown() } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open, closeDropdown, inputId]) // Scroll highlighted item into view useEffect(() => { if (highlighted < 0 || !listRef.current) return const item = listRef.current.querySelectorAll('[data-option]')[highlighted] item?.scrollIntoView({ block: 'nearest' }) }, [highlighted]) const selectOption = useCallback((opt) => { if (opt.disabled) return if (multi) { const exists = selectedSet.has(String(opt.value)) onChange(exists ? selected.filter((v) => String(v) !== String(opt.value)) : [...selected, opt.value]) setSearch('') searchRef.current?.focus() } else { onChange(opt.value) closeDropdown() triggerRef.current?.focus() } }, [multi, selected, selectedSet, onChange, closeDropdown]) const clearValue = useCallback((e) => { e.stopPropagation() onChange(multi ? [] : null) }, [multi, onChange]) const removeTag = useCallback((val, e) => { e.stopPropagation() onChange(selected.filter((v) => String(v) !== String(val))) }, [selected, onChange]) // Keyboard handler on search/trigger const handleKeyDown = useCallback((e) => { if (!open) { if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) { e.preventDefault() openDropdown() } return } switch (e.key) { case 'ArrowDown': e.preventDefault() setHigh((h) => Math.min(h + 1, filtered.length - 1)) break case 'ArrowUp': e.preventDefault() setHigh((h) => Math.max(h - 1, 0)) break case 'Enter': e.preventDefault() if (highlighted >= 0 && filtered[highlighted]) selectOption(filtered[highlighted]) break case 'Escape': e.preventDefault() closeDropdown() triggerRef.current?.focus() break case 'Backspace': if (multi && !search && selected.length > 0) { onChange(selected.slice(0, -1)) } break default: break } }, [open, filtered, highlighted, search, multi, selected, selectOption, closeDropdown, openDropdown, onChange]) // Build display label(s) const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options]) const hasValue = selected.length > 0 // Trigger appearance const triggerClass = [ 'relative w-full flex items-center min-h-[42px] rounded-xl border px-3 py-1.5 gap-2 cursor-pointer', 'bg-white/[0.06] text-sm text-white transition-all duration-150', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0', error ? 'border-red-500/60 focus-visible:ring-red-500/40' : open ? 'border-accent/50 ring-2 ring-accent/40' : 'border-white/12 hover:border-white/22', disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '', className, ].join(' ') return (
{error}
} {!error && hint &&{hint}
} {/* Dropdown portal */} {open && createPortal(No options found
) : ( filtered.map((opt, idx) => { const isSelected = selectedSet.has(String(opt.value)) const isHighlighted = idx === highlighted const prevGroup = idx > 0 ? filtered[idx - 1].group : undefined const showGroupHeader = opt.group != null && opt.group !== prevGroup return (