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
This commit is contained in:
415
resources/js/components/ui/NovaSelect.jsx
Normal file
415
resources/js/components/ui/NovaSelect.jsx
Normal file
@@ -0,0 +1,415 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user