Files
SkinbaseNova/resources/js/components/ui/NovaSelect.jsx
Gregor Klevze a875203482 feat: Nova UI component library + Studio dropdown/picker polish
- Add Nova UI library: Button, TextInput, Textarea, FormField, Select,
  NovaSelect, Checkbox, Radio/RadioGroup, Toggle, DatePicker,
  DateRangePicker, Modal + barrel index.js
- Replace all native <select> in Studio with NovaSelect (StudioFilters,
  StudioToolbar, BulkActionsBar) including frosted-glass portal and
  category group headers
- Replace native checkboxes in StudioGridCard, StudioTable, UploadSidebar,
  UploadWizard, Upload/Index with custom Checkbox component
- Add nova-scrollbar CSS utility (thin 4px, semi-transparent)
- Fix portal position drift: use viewport-relative coords (no scrollY offset)
  for NovaSelect, DatePicker and DateRangePicker
- Close portals on external scroll instead of remeasuring
- Improve hover highlight visibility in NovaSelect (bg-white/[0.13])
- Move search icon to right side in NovaSelect dropdown
- Reduce Studio layout top spacing (py-6 -> pt-4 pb-8)
- Add StudioCheckbox and SquareCheckbox backward-compat shims
- Add sync.sh rsync deploy script
2026-03-01 10:41:43 +01:00

416 lines
15 KiB
JavaScript
Raw 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
*/
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
setDropPos({
top: openUp ? rect.top - dropH - 4 : rect.bottom + 4,
left: rect.left,
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 (
<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 && (
<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="Search…"
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>
)
}