Files
SkinbaseNova/resources/js/components/ui/NovaSelect.jsx

427 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
* @prop {function} renderValue - custom render fn for single-value trigger: (option) => ReactNode
* @prop {string} searchPlaceholder - placeholder shown in the dropdown search input
*/
export default function NovaSelect({
options = [],
value,
onChange,
multi = false,
placeholder = 'Select…',
searchable = true,
clearable = false,
label,
error,
hint,
required = false,
disabled = false,
renderOption,
renderValue,
id,
className = '',
searchPlaceholder = 'Search…',
}) {
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 optionMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o])), [options])
const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options])
const hasValue = selected.length > 0
const selectedOption = !multi && hasValue ? optionMap[String(selected[0])] ?? null : null
// 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 (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-white/85 select-none">
{label}{required && <span className="text-red-400 ml-1">*</span>}
</label>
)}
{/* Trigger button */}
<div
ref={triggerRef}
id={inputId}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-label={label ?? placeholder}
tabIndex={disabled ? -1 : 0}
className={triggerClass}
onClick={open ? closeDropdown : openDropdown}
onKeyDown={handleKeyDown}
>
{/* Tags (multi) or selected label (single) */}
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
{multi && selected.map((v) => (
<span
key={v}
className="inline-flex items-center gap-1 h-6 px-2 rounded-md bg-accent/20 text-accent text-xs font-medium"
>
{labelMap[String(v)] ?? v}
<button
type="button"
tabIndex={-1}
onClick={(e) => removeTag(v, e)}
className="hover:text-white transition-colors"
aria-label={`Remove ${labelMap[String(v)] ?? v}`}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 1l6 6M7 1L1 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</span>
))}
{!multi && hasValue && (
renderValue && selectedOption
? renderValue(selectedOption)
: <span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
)}
{!hasValue && (
<span className="text-slate-500 truncate">{placeholder}</span>
)}
</div>
{/* Right icons */}
<div className="flex items-center gap-1 shrink-0">
{clearable && hasValue && (
<button
type="button"
tabIndex={-1}
onClick={clearValue}
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:text-white transition-colors"
aria-label="Clear"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
)}
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
className={`text-slate-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
{/* Dropdown portal */}
{open && createPortal(
<div
id={`nova-select-dropdown-${inputId}`}
role="listbox"
aria-multiselectable={multi}
className="fixed z-[500] flex flex-col rounded-xl border border-white/12 bg-nova-900/80 backdrop-blur-xl shadow-2xl shadow-black/50 overflow-hidden"
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width, maxHeight: 280 }}
>
{/* Search */}
{searchable && (
<div className="px-2 pt-2 pb-1 border-b border-white/8">
<div className="relative">
<svg
width="12" height="12" viewBox="0 0 12 12" fill="none"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none"
aria-hidden="true"
>
<circle cx="5" cy="5" r="3.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 8l2.5 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
ref={searchRef}
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setHigh(0) }}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
className="w-full pl-3 pr-7 py-1.5 rounded-lg bg-white/5 border border-white/8 text-white text-xs placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent/50"
autoComplete="off"
/>
</div>
</div>
)}
{/* Options */}
<div ref={listRef} className="overflow-y-auto flex-1 nova-scrollbar" style={{ maxHeight: 220 }}>
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-slate-500">No options found</p>
) : (
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 (
<React.Fragment key={String(opt.value)}>
{showGroupHeader && (
<div className={`px-3 pt-2 pb-0.5 text-[10px] font-semibold text-slate-500 uppercase tracking-widest select-none${idx > 0 ? ' border-t border-white/5' : ''}`}>
{opt.group}
</div>
)}
<div
data-option
role="option"
aria-selected={isSelected}
aria-disabled={opt.disabled}
onClick={() => selectOption(opt)}
onMouseEnter={() => setHigh(idx)}
className={[
'flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer transition-colors duration-75',
opt.disabled ? 'opacity-40 cursor-not-allowed' : '',
isHighlighted ? 'bg-white/[0.13]' : 'hover:bg-white/[0.07]',
isSelected ? 'text-accent' : 'text-white/85',
].join(' ')}
>
{/* Checkmark in multi mode */}
{multi && (
<span className={[
'w-4 h-4 shrink-0 rounded border flex items-center justify-center transition-colors',
isSelected ? 'bg-accent border-accent' : 'border-white/25 bg-white/5',
].join(' ')}>
{isSelected && (
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" aria-hidden="true">
<path d="M1.5 4.5l2 2 4-4" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
)}
{opt.icon && <span className="shrink-0">{opt.icon}</span>}
{renderOption ? renderOption(opt) : (
<span className="flex-1 truncate">{opt.label}</span>
)}
{/* Tick in single mode */}
{!multi && isSelected && (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="text-accent shrink-0" aria-hidden="true">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
</React.Fragment>
)
})
)}
</div>
</div>,
document.body,
)}
</div>
)
}