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:
2026-03-01 10:41:43 +01:00
parent e3ca845a6d
commit a875203482
26 changed files with 2087 additions and 132 deletions

View 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>
)
}