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

@@ -68,9 +68,9 @@ export default function StudioLayout({ children, title }) {
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 px-4 lg:px-8 py-6">
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8">
{title && (
<h1 className="text-2xl font-bold text-white mb-6">{title}</h1>
<h1 className="text-2xl font-bold text-white mb-4">{title}</h1>
)}
{children}
</main>

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState }
import { usePage } from '@inertiajs/react'
import TagInput from '../../components/tags/TagInput'
import UploadWizard from '../../components/upload/UploadWizard'
import Checkbox from '../../Components/ui/Checkbox'
const phases = {
idle: 'idle',
@@ -918,15 +919,15 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
/>
</label>
<label className="mt-4 inline-flex items-center gap-3 text-sm text-white/80">
<input
type="checkbox"
<div className="mt-4">
<Checkbox
checked={state.metadata.licenseAccepted}
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { licenseAccepted: e.target.checked } })}
className="h-4 w-4 shrink-0 border border-white/30 bg-transparent accent-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400"
size={16}
variant="emerald"
label="I confirm I own the rights to this artwork."
/>
I confirm I own the rights to this artwork.
</label>
</div>
{state.cancelledAt && (
<div role="alert" aria-live="polite" className="mt-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'
import NovaSelect from '../ui/NovaSelect'
const actions = [
{ value: 'publish', label: 'Publish', icon: 'fa-eye', danger: false },
@@ -37,18 +38,15 @@ export default function BulkActionsBar({ count, onExecute, onClearSelection }) {
</div>
<div className="flex items-center gap-2">
<select
value={action}
onChange={(e) => setAction(e.target.value)}
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 min-w-[180px]"
>
<option value="" className="bg-nova-900">Choose action</option>
{actions.map((a) => (
<option key={a.value} value={a.value} className="bg-nova-900">
{a.label}
</option>
))}
</select>
<div className="min-w-[180px]">
<NovaSelect
options={actions.map((a) => ({ value: a.value, label: a.label }))}
value={action || null}
onChange={(val) => setAction(val ?? '')}
placeholder="Choose action…"
searchable={false}
/>
</div>
<button
onClick={handleExecute}

View File

@@ -0,0 +1,5 @@
/**
* @deprecated Use the unified Checkbox from Components/ui instead.
* This shim exists only for backward compatibility.
*/
export { default } from '../ui/Checkbox'

View File

@@ -1,17 +1,17 @@
import React from 'react'
import React, { useMemo } from 'react'
import DateRangePicker from '../ui/DateRangePicker'
import NovaSelect from '../ui/NovaSelect'
const statusOptions = [
{ value: '', label: 'All statuses' },
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
{ value: 'archived', label: 'Archived' },
{ value: 'draft', label: 'Draft' },
{ value: 'archived', label: 'Archived' },
]
const performanceOptions = [
{ value: '', label: 'All performance' },
{ value: 'rising', label: 'Rising (hot)' },
{ value: 'top', label: 'Top performers' },
{ value: 'low', label: 'Low performers' },
{ value: 'top', label: 'Top performers' },
{ value: 'low', label: 'Low performers' },
]
export default function StudioFilters({
@@ -27,13 +27,26 @@ export default function StudioFilters({
onFilterChange({ ...filters, [key]: value })
}
const categoryOptions = useMemo(() => {
const opts = []
categories.forEach((ct) => {
ct.categories?.forEach((cat) => {
opts.push({ value: cat.slug, label: cat.name, group: ct.name })
cat.children?.forEach((ch) => {
opts.push({ value: ch.slug, label: ch.name, group: ct.name })
})
})
})
return opts
}, [categories])
return (
<>
{/* Mobile backdrop */}
<div className="lg:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* Filter panel */}
<div className="fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 lg:w-64 bg-nova-900 lg:bg-nova-900/40 border-r lg:border border-white/10 lg:rounded-2xl p-5 space-y-5 overflow-y-auto lg:static lg:mb-4">
<div className="fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 lg:w-64 bg-nova-900 lg:bg-nova-900/40 border-r lg:border border-white/10 lg:rounded-2xl p-5 space-y-5 overflow-y-auto nova-scrollbar lg:static lg:mb-4">
<div className="flex items-center justify-between lg:hidden">
<h3 className="text-base font-semibold text-white">Filters</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white" aria-label="Close filters">
@@ -44,75 +57,48 @@ export default function StudioFilters({
<h3 className="hidden lg:block text-sm font-semibold text-slate-400 uppercase tracking-wider">Filters</h3>
{/* Status */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Status</label>
<select
value={filters.status || ''}
onChange={(e) => handleChange('status', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{statusOptions.map((o) => (
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
))}
</select>
</div>
<NovaSelect
label="Status"
options={statusOptions}
value={filters.status || null}
onChange={(val) => handleChange('status', val ?? '')}
placeholder="All statuses"
searchable={false}
clearable
/>
{/* Category */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
<select
value={filters.category || ''}
onChange={(e) => handleChange('category', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
<option value="" className="bg-nova-900">All categories</option>
{categories.map((ct) => (
<optgroup key={ct.id} label={ct.name}>
{ct.categories?.map((cat) => (
<React.Fragment key={cat.id}>
<option value={cat.slug} className="bg-nova-900">{cat.name}</option>
{cat.children?.map((ch) => (
<option key={ch.id} value={ch.slug} className="bg-nova-900">&nbsp;&nbsp;{ch.name}</option>
))}
</React.Fragment>
))}
</optgroup>
))}
</select>
</div>
<NovaSelect
label="Category"
options={categoryOptions}
value={filters.category || null}
onChange={(val) => handleChange('category', val ?? '')}
placeholder="All categories"
clearable
/>
{/* Performance */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Performance</label>
<select
value={filters.performance || ''}
onChange={(e) => handleChange('performance', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{performanceOptions.map((o) => (
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
))}
</select>
</div>
<NovaSelect
label="Performance"
options={performanceOptions}
value={filters.performance || null}
onChange={(val) => handleChange('performance', val ?? '')}
placeholder="All performance"
searchable={false}
clearable
/>
{/* Date range */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Date range</label>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
value={filters.date_from || ''}
onChange={(e) => handleChange('date_from', e.target.value)}
className="px-2 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
<input
type="date"
value={filters.date_to || ''}
onChange={(e) => handleChange('date_to', e.target.value)}
className="px-2 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
</div>
<DateRangePicker
label="Date range"
start={filters.date_from || ''}
end={filters.date_to || ''}
onChange={({ start, end }) => {
onFilterChange({ ...filters, date_from: start, date_to: end })
}}
clearable
placeholder="Any date"
/>
{/* Clear */}
<button

View File

@@ -1,6 +1,7 @@
import React from 'react'
import StatusBadge from '../Badges/StatusBadge'
import RisingBadge from '../Badges/RisingBadge'
import Checkbox from '../ui/Checkbox'
function getStatus(art) {
if (art.deleted_at) return 'archived'
@@ -27,14 +28,13 @@ export default function StudioGridCard({ artwork, selected, onSelect, onAction }
}`}
>
{/* Selection checkbox */}
<label className="absolute top-3 left-3 z-10 cursor-pointer">
<input
type="checkbox"
<div className="absolute top-3 left-3 z-10">
<Checkbox
checked={selected}
onChange={() => onSelect(artwork.id)}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
aria-label={`Select ${artwork.title}`}
/>
</label>
</div>
{/* Thumbnail */}
<div className="relative aspect-[4/3] bg-nova-800 overflow-hidden">

View File

@@ -1,6 +1,7 @@
import React from 'react'
import StatusBadge from '../Badges/StatusBadge'
import RisingBadge from '../Badges/RisingBadge'
import Checkbox from '../ui/Checkbox'
function getStatus(art) {
if (art.deleted_at) return 'archived'
@@ -46,11 +47,10 @@ export default function StudioTable({ artworks, selectedIds, onSelect, onSelectA
<thead className="sticky top-0 z-10 bg-nova-900/90 backdrop-blur-sm border-b border-white/10">
<tr>
<th className="p-3 w-10">
<input
type="checkbox"
<Checkbox
checked={allSelected}
onChange={onSelectAll}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
aria-label="Select all artworks"
/>
</th>
<th className="p-3 w-12"></th>
@@ -74,11 +74,10 @@ export default function StudioTable({ artworks, selectedIds, onSelect, onSelectA
className={`transition-colors ${selectedIds.includes(art.id) ? 'bg-accent/5' : 'hover:bg-white/[0.02]'}`}
>
<td className="p-3">
<input
type="checkbox"
<Checkbox
checked={selectedIds.includes(art.id)}
onChange={() => onSelect(art.id)}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
aria-label={`Select ${art.title}`}
/>
</td>
<td className="p-3">

View File

@@ -1,4 +1,5 @@
import React from 'react'
import NovaSelect from '../ui/NovaSelect'
const sortOptions = [
{ value: 'created_at:desc', label: 'Latest' },
@@ -37,17 +38,14 @@ export default function StudioToolbar({
</div>
{/* Sort */}
<select
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="px-3 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none cursor-pointer min-w-[160px]"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value} className="bg-nova-900 text-white">
{opt.label}
</option>
))}
</select>
<div className="min-w-[160px]">
<NovaSelect
options={sortOptions}
value={sort}
onChange={onSortChange}
searchable={false}
/>
</div>
{/* Filter toggle */}
<button

View File

@@ -0,0 +1,83 @@
import React from 'react'
/**
* Nova Button
*
* @prop {string} variant - 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'accent'
* @prop {string} size - 'xs' | 'sm' | 'md' | 'lg'
* @prop {boolean} loading - shows spinner, disables button
* @prop {boolean} iconOnly - makes button square (for icon-only buttons)
* @prop {React.ReactNode} leftIcon / rightIcon - icon elements
*/
const variantClasses = {
primary: 'bg-sky-600 hover:bg-sky-500 text-white shadow-lg shadow-sky-600/20 focus-visible:ring-sky-500/50',
accent: 'bg-accent hover:bg-accent/90 text-white shadow-lg shadow-accent/25 focus-visible:ring-accent/50',
secondary: 'bg-white/8 hover:bg-white/14 text-white border border-white/15 focus-visible:ring-white/30',
ghost: 'bg-transparent hover:bg-white/8 text-slate-300 hover:text-white focus-visible:ring-white/20',
danger: 'bg-red-600 hover:bg-red-700 text-white shadow-lg shadow-red-600/20 focus-visible:ring-red-500/50',
success: 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-600/20 focus-visible:ring-emerald-500/50',
}
const sizeClasses = {
xs: 'px-2.5 py-1 text-xs rounded-lg gap-1.5',
sm: 'px-3.5 py-1.5 text-sm rounded-xl gap-2',
md: 'px-5 py-2.5 text-sm rounded-xl gap-2',
lg: 'px-6 py-3 text-base rounded-xl gap-2.5',
}
const iconOnlySizeClasses = {
xs: 'w-7 h-7 rounded-lg',
sm: 'w-8 h-8 rounded-lg',
md: 'w-10 h-10 rounded-xl',
lg: 'w-12 h-12 rounded-xl',
}
export default function Button({
variant = 'primary',
size = 'md',
loading = false,
iconOnly = false,
leftIcon,
rightIcon,
disabled,
className = '',
children,
type = 'button',
...rest
}) {
const isDisabled = disabled || loading
const base = [
'inline-flex items-center justify-center font-semibold transition-all duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none',
variantClasses[variant] ?? variantClasses.primary,
iconOnly ? iconOnlySizeClasses[size] ?? iconOnlySizeClasses.md : sizeClasses[size] ?? sizeClasses.md,
className,
].join(' ')
return (
<button type={type} disabled={isDisabled} className={base} {...rest}>
{loading ? (
<svg
className="animate-spin shrink-0"
style={{ width: '1em', height: '1em' }}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
) : leftIcon ? (
<span className="shrink-0">{leftIcon}</span>
) : null}
{!iconOnly && children && <span>{children}</span>}
{iconOnly && !loading && children}
{rightIcon && !loading && <span className="shrink-0">{rightIcon}</span>}
</button>
)
}

View File

@@ -0,0 +1,113 @@
import React, { forwardRef } from 'react'
/**
* Nova Checkbox fully custom rendering (appearance-none + SVG tick).
* Avoids @tailwindcss/forms overriding the checked background colour.
*
* @prop {string} label - label text rendered alongside the box
* @prop {string} hint - small helper line below label
* @prop {string} error - inline error
* @prop {number|string} size - pixel size (default 18)
* @prop {string} variant - 'accent' | 'emerald' | 'sky'
*/
const variantStyles = {
accent: { checked: '#E07A21', ring: 'rgba(224,122,33,0.45)' },
emerald: { checked: '#10b981', ring: 'rgba(16,185,129,0.45)' },
sky: { checked: '#0ea5e9', ring: 'rgba(14,165,233,0.45)' },
}
const Checkbox = forwardRef(function Checkbox(
{ label, hint, error, size = 18, variant = 'accent', id, className = '', checked, disabled, onChange, ...rest },
ref,
) {
const dim = typeof size === 'number' ? `${size}px` : size
const numSize = typeof size === 'number' ? size : parseInt(size, 10)
const inputId = id ?? (label ? `cb-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined)
const colors = variantStyles[variant] ?? variantStyles.accent
// Tick sizes relative to box
const tickInset = Math.round(numSize * 0.18)
const strokeWidth = Math.max(1.5, numSize * 0.1)
return (
<div className="flex flex-col gap-1">
<label
className={[
'inline-flex items-start gap-2.5 select-none',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
].join(' ')}
>
{/* Hidden native input keeps full a11y / form submission */}
<input
type="checkbox"
id={inputId}
ref={ref}
checked={checked}
disabled={disabled}
onChange={onChange}
className="sr-only"
aria-invalid={!!error}
{...rest}
/>
{/* Visual box */}
<span
aria-hidden="true"
style={{
width: dim,
height: dim,
minWidth: dim,
minHeight: dim,
aspectRatio: '1 / 1',
marginTop: label ? '1px' : undefined,
backgroundColor: checked ? colors.checked : 'rgba(255,255,255,0.06)',
borderColor: checked ? colors.checked : 'rgba(255,255,255,0.25)',
boxShadow: checked ? `0 0 0 0px ${colors.ring}` : undefined,
transition: 'background-color 150ms, border-color 150ms',
}}
className={[
'shrink-0 inline-flex items-center justify-center',
'rounded-md border',
className,
].join(' ')}
>
{/* SVG tick — only visible when checked */}
<svg
viewBox="0 0 12 12"
fill="none"
style={{
width: numSize - tickInset * 2,
height: numSize - tickInset * 2,
opacity: checked ? 1 : 0,
transition: 'opacity 100ms',
}}
aria-hidden="true"
>
<path
d="M1.5 6l3 3 6-6"
stroke="white"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
{(label || hint) && (
<span className="flex flex-col gap-0.5">
{label && <span className="text-sm text-white/90 leading-snug">{label}</span>}
{hint && <span className="text-xs text-slate-500">{hint}</span>}
</span>
)}
</label>
{error && (
<p role="alert" className="text-xs text-red-400" style={{ paddingLeft: `calc(${dim} + 0.625rem)` }}>
{error}
</p>
)}
</div>
)
})
export default Checkbox

View File

@@ -0,0 +1,341 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
/* ─── Date helpers ────────────────────────────────────────────── */
const MONTH_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
]
const DAY_ABBR = ['Mo','Tu','We','Th','Fr','Sa','Su']
function daysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate()
}
/** Returns 0=Mon … 6=Sun for first day of month */
function firstWeekday(year, month) {
const d = new Date(year, month, 1).getDay() // 0=Sun
return (d + 6) % 7 // shift so 0=Mon
}
function toISO(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function fromISO(str) {
if (!str) return null
const [y, m, d] = str.split('-').map(Number)
return new Date(y, m - 1, d)
}
function formatDisplay(isoStr) {
if (!isoStr) return ''
const d = fromISO(isoStr)
if (!d) return ''
return `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()}`
}
function isSameDay(a, b) {
return a?.getFullYear() === b?.getFullYear() &&
a?.getMonth() === b?.getMonth() &&
a?.getDate() === b?.getDate()
}
/* ─── Calendar Grid ───────────────────────────────────────────── */
function CalendarGrid({ year, month, selected, onSelect, minDate, maxDate }) {
const numDays = daysInMonth(year, month)
const startWd = firstWeekday(year, month)
const prevDays = daysInMonth(year, month - 1 < 0 ? 11 : month - 1)
const cells = []
// Filler from previous month
for (let i = startWd - 1; i >= 0; i--) {
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
}
// Current month
for (let d = 1; d <= numDays; d++) {
cells.push({ day: d, current: true, date: new Date(year, month, d) })
}
// Next month filler to fill 6 rows
let next = 1
while (cells.length % 7 !== 0) {
cells.push({ day: next++, current: false, date: new Date(year, month + 1, next - 1) })
}
const selectedDate = fromISO(selected)
const today = new Date()
today.setHours(0, 0, 0, 0)
return (
<div className="p-3">
{/* Day-of-week headers */}
<div className="grid grid-cols-7 mb-1">
{DAY_ABBR.map((d) => (
<div key={d} className="text-center text-[10px] font-semibold text-slate-500 py-1">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-y-0.5">
{cells.map((cell, i) => {
const iso = toISO(cell.date)
const isSelected = isSameDay(cell.date, selectedDate)
const isToday = isSameDay(cell.date, today)
const disabled =
(minDate && iso < minDate) ||
(maxDate && iso > maxDate)
return (
<button
key={i}
type="button"
disabled={disabled}
onClick={() => onSelect(iso)}
className={[
'relative flex items-center justify-center w-8 h-8 mx-auto rounded-lg text-sm transition-all',
!cell.current ? 'text-slate-600' : '',
cell.current && !isSelected && !disabled ? 'hover:bg-white/10 text-white' : '',
isSelected ? 'bg-accent text-white font-semibold shadow shadow-accent/30' : '',
isToday && !isSelected ? 'ring-1 ring-accent/50 text-accent' : '',
disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer',
].join(' ')}
>
{cell.day}
</button>
)
})}
</div>
</div>
)
}
/* ─── DatePicker ──────────────────────────────────────────────── */
/**
* Nova DatePicker
*
* @prop {string} value - ISO date string 'YYYY-MM-DD' or ''
* @prop {function} onChange - called with ISO string
* @prop {string} label
* @prop {string} placeholder
* @prop {string} error
* @prop {string} hint
* @prop {boolean} required
* @prop {boolean} clearable
* @prop {string} minDate - ISO string lower bound
* @prop {string} maxDate - ISO string upper bound
*/
export default function DatePicker({
value = '',
onChange,
label,
placeholder = 'Pick a date',
error,
hint,
required = false,
clearable = false,
minDate,
maxDate,
id,
disabled = false,
className = '',
}) {
const [open, setOpen] = useState(false)
const [dropPos, setPos] = useState({ top: 0, left: 0, width: 240, openUp: false })
const today = new Date()
const initVal = value ? fromISO(value) : today
const [viewYear, setYear] = useState(initVal?.getFullYear() ?? today.getFullYear())
const [viewMonth, setMonth] = useState(initVal?.getMonth() ?? today.getMonth())
const triggerRef = useRef(null)
const inputId = id ?? (label ? `dp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-picker')
const measure = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const height = 320
const openUp = window.innerHeight - rect.bottom < height + 8 && rect.top > height + 8
setPos({
top: openUp ? rect.top - height - 4 : rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 280),
openUp,
})
}, [])
const openPicker = () => {
if (disabled) return
if (value) {
const d = fromISO(value)
if (d) { setYear(d.getFullYear()); setMonth(d.getMonth()) }
}
measure()
setOpen(true)
}
// close on outside click
useEffect(() => {
if (!open) return
const handler = (e) => {
if (
!triggerRef.current?.contains(e.target) &&
!document.getElementById(`dp-panel-${inputId}`)?.contains(e.target)
) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open, inputId])
useEffect(() => {
if (!open) return
const onScroll = (e) => {
if (document.getElementById(`dp-panel-${inputId}`)?.contains(e.target)) return
setOpen(false)
}
const onResize = () => setOpen(false)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onResize)
}
}, [open, inputId])
const prevMonth = () => {
if (viewMonth === 0) { setMonth(11); setYear((y) => y - 1) }
else setMonth((m) => m - 1)
}
const nextMonth = () => {
if (viewMonth === 11) { setMonth(0); setYear((y) => y + 1) }
else setMonth((m) => m + 1)
}
const handleSelect = (iso) => {
onChange?.(iso)
setOpen(false)
}
const clearValue = (e) => { e.stopPropagation(); onChange?.('') }
const triggerClass = [
'relative flex items-center h-[42px] rounded-xl border px-3.5 gap-2 cursor-pointer w-full',
'bg-white/[0.06] text-sm 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>
)}
<div ref={triggerRef} className={triggerClass} tabIndex={disabled ? -1 : 0} onClick={openPicker}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }}
role="button" aria-label={label ?? placeholder} id={inputId}
>
{/* Calendar icon */}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-slate-500 shrink-0" aria-hidden="true">
<rect x="1" y="2.5" width="12" height="10.5" rx="1.5" stroke="currentColor" strokeWidth="1.3" />
<path d="M1 6h12" stroke="currentColor" strokeWidth="1.3" />
<path d="M4 1v3M10 1v3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
<circle cx="4.5" cy="9" r="0.75" fill="currentColor" />
<circle cx="7" cy="9" r="0.75" fill="currentColor" />
<circle cx="9.5" cy="9" r="0.75" fill="currentColor" />
</svg>
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
{value ? formatDisplay(value) : placeholder}
</span>
{clearable && value && (
<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 date"
>
<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>
)}
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
{open && createPortal(
<div
id={`dp-panel-${inputId}`}
className="fixed z-[500] rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50 overflow-hidden"
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
>
{/* Month nav header */}
<div className="flex items-center justify-between px-3 pt-3">
<button type="button" onClick={prevMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-white hover:bg-white/8 transition-all"
aria-label="Previous month"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M7 1L3 5l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<span className="text-sm font-semibold text-white">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
<button type="button" onClick={nextMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-white hover:bg-white/8 transition-all"
aria-label="Next month"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
<CalendarGrid
year={viewYear}
month={viewMonth}
selected={value}
onSelect={handleSelect}
minDate={minDate}
maxDate={maxDate}
/>
{/* Today shortcut */}
<div className="border-t border-white/8 px-3 py-2 flex justify-end">
<button
type="button"
onClick={() => handleSelect(toISO(new Date()))}
className="text-xs text-accent hover:text-accent/80 font-medium transition-colors"
>
Today
</button>
</div>
</div>,
document.body,
)}
</div>
)
}

View File

@@ -0,0 +1,351 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
/* ─── Date helpers (duplicated locally so component is self-contained) ─ */
const MONTH_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
]
const DAY_ABBR = ['Mo','Tu','We','Th','Fr','Sa','Su']
function daysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate()
}
function firstWeekday(year, month) {
return (new Date(year, month, 1).getDay() + 6) % 7
}
function toISO(date) {
return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
}
function fromISO(str) {
if (!str) return null
const [y,m,d] = str.split('-').map(Number)
return new Date(y, m-1, d)
}
function fmt(isoStr) {
if (!isoStr) return ''
const d = fromISO(isoStr)
return d ? `${MONTH_NAMES[d.getMonth()].slice(0,3)} ${d.getDate()}, ${d.getFullYear()}` : ''
}
function isSameDay(a, b) {
return !!a && !!b &&
a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate()
}
/* ─── Single month grid ─────────────────────────────────────────── */
function MonthGrid({ year, month, start, end, hover, onHover, onSelect, minDate, maxDate }) {
const numDays = daysInMonth(year, month)
const startWd = firstWeekday(year, month)
const prevDays = daysInMonth(year, month - 1 < 0 ? 11 : month - 1)
const cells = []
for (let i = startWd - 1; i >= 0; i--)
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
for (let d = 1; d <= numDays; d++)
cells.push({ day: d, current: true, date: new Date(year, month, d) })
let next = 1
while (cells.length % 7 !== 0)
cells.push({ day: next++, current: false, date: new Date(year, month + 1, next - 1) })
const startDate = fromISO(start)
const endDate = fromISO(end)
const hoverDate = hover ? fromISO(hover) : null
const today = new Date(); today.setHours(0,0,0,0)
// Range boundary to highlight (end could be hover while selecting)
const rangeEnd = endDate ?? hoverDate
return (
<div className="p-3 min-w-[224px]">
<div className="grid grid-cols-7 mb-1">
{DAY_ABBR.map((d) => (
<div key={d} className="text-center text-[10px] font-semibold text-slate-500 py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-y-0.5">
{cells.map((cell, i) => {
const iso = toISO(cell.date)
const isStart = isSameDay(cell.date, startDate)
const isEnd = isSameDay(cell.date, endDate)
const isToday = isSameDay(cell.date, today)
const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate)
// In range?
let inRange = false
if (startDate && rangeEnd) {
const lo = startDate <= rangeEnd ? startDate : rangeEnd
const hi = startDate <= rangeEnd ? rangeEnd : startDate
inRange = cell.date > lo && cell.date < hi
}
const isEdge = isStart || isEnd
return (
<button
key={i}
type="button"
disabled={disabled || !cell.current}
onClick={() => cell.current && !disabled && onSelect(iso)}
onMouseEnter={() => cell.current && !disabled && onHover(iso)}
className={[
'relative flex items-center justify-center w-8 h-8 mx-auto rounded-lg text-sm transition-all',
!cell.current ? 'opacity-0 pointer-events-none' : '',
isEdge ? 'bg-accent text-white font-semibold shadow shadow-accent/30 z-10' : '',
!isEdge && inRange ? 'bg-accent/20 text-white rounded-none' : '',
!isEdge && !inRange && !disabled ? 'hover:bg-white/10 text-white' : '',
isToday && !isEdge ? 'ring-1 ring-accent/50 text-accent' : '',
disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer',
].join(' ')}
>
{cell.day}
</button>
)
})}
</div>
</div>
)
}
/* ─── DateRangePicker ────────────────────────────────────────────── */
/**
* Nova DateRangePicker
*
* @prop {string} start - ISO 'YYYY-MM-DD'
* @prop {string} end - ISO 'YYYY-MM-DD'
* @prop {function} onChange - called with { start, end }
* @prop {string} label
* @prop {string} placeholder
* @prop {string} error
* @prop {string} hint
* @prop {boolean} required
* @prop {boolean} clearable
* @prop {string} minDate
* @prop {string} maxDate
*/
export default function DateRangePicker({
start = '',
end = '',
onChange,
label,
placeholder = 'Select date range',
error,
hint,
required = false,
clearable = false,
minDate,
maxDate,
id,
disabled = false,
className = '',
}) {
const [open, setOpen] = useState(false)
const [dropPos, setPos] = useState({ top: 0, left: 0, width: 480 })
const [hover, setHover] = useState('')
// Selecting state: if we have a start but no end, next click sets end
const [picking, setPicking] = useState(null) // null | 'start' | 'end'
// View months: left and right panels
const today = new Date()
const [lYear, setLYear] = useState(today.getFullYear())
const [lMonth, setLMonth] = useState(today.getMonth() === 0 ? 11 : today.getMonth() - 1)
// right panel = left + 1
const rYear = lMonth === 11 ? lYear + 1 : lYear
const rMonth = lMonth === 11 ? 0 : lMonth + 1
const triggerRef = useRef(null)
const inputId = id ?? (label ? `drp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-range')
const measure = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const panelW = Math.max(rect.width, 480)
const height = 340
const openUp = window.innerHeight - rect.bottom < height + 8 && rect.top > height + 8
setPos({
top: openUp ? rect.top - height - 4 : rect.bottom + 4,
left: Math.min(rect.left, window.innerWidth - panelW - 8),
width: panelW,
})
}, [])
const openPicker = () => { if (disabled) return; measure(); setOpen(true) }
useEffect(() => {
if (!open) return
const handler = (e) => {
if (
!triggerRef.current?.contains(e.target) &&
!document.getElementById(`drp-panel-${inputId}`)?.contains(e.target)
) { setOpen(false); setPicking(null) }
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open, inputId])
useEffect(() => {
if (!open) return
const onScroll = (e) => {
if (document.getElementById(`drp-panel-${inputId}`)?.contains(e.target)) return
setOpen(false); setPicking(null)
}
const onResize = () => { setOpen(false); setPicking(null) }
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize) }
}, [open, inputId])
const handleSelect = (iso) => {
if (!start || picking === 'start' || (start && end)) {
// Start fresh
onChange?.({ start: iso, end: '' })
setPicking('end')
setHover('')
} else {
// We have start, picking end
const s = start < iso ? start : iso
const e = start < iso ? iso : start
onChange?.({ start: s, end: e })
setPicking(null)
setOpen(false)
}
}
const clearValue = (ev) => { ev.stopPropagation(); onChange?.({ start:'', end:'' }); setPicking(null) }
const prevLeft = () => {
if (lMonth === 0) { setLMonth(11); setLYear(y => y - 1) } else setLMonth(m => m - 1)
}
const nextLeft = () => {
if (lMonth === 11) { setLMonth(0); setLYear(y => y + 1) } else setLMonth(m => m + 1)
}
const displayText = start ? `${fmt(start)} ${end ? fmt(end) : '…'}` : ''
const triggerClass = [
'relative flex items-center h-[42px] rounded-xl border px-3.5 gap-2 cursor-pointer w-full',
'bg-white/[0.06] text-sm 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>
)}
<div ref={triggerRef} className={triggerClass} tabIndex={disabled ? -1 : 0}
onClick={openPicker}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }}
role="button" aria-label={label ?? placeholder} id={inputId}
>
{/* Calendar icon */}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-slate-500 shrink-0" aria-hidden="true">
<rect x="1" y="2.5" width="12" height="10.5" rx="1.5" stroke="currentColor" strokeWidth="1.3"/>
<path d="M1 6h12" stroke="currentColor" strokeWidth="1.3"/>
<path d="M4 1v3M10 1v3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
</svg>
<span className={`flex-1 truncate ${displayText ? 'text-white' : 'text-slate-500'}`}>
{displayText || placeholder}
</span>
{clearable && (start || end) && (
<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 date range"
>
<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>
)}
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
{open && createPortal(
<div
id={`drp-panel-${inputId}`}
className="fixed z-[500] rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50 overflow-hidden"
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
onMouseLeave={() => setHover('')}
>
{/* Header info */}
{picking === 'end' && (
<div className="px-4 pt-3 pb-1 text-xs text-accent/80 font-medium">
Now click an end date
</div>
)}
{/* Two calendars */}
<div className="flex items-start">
{/* Left month */}
<div className="flex-1 border-r border-white/8">
<div className="flex items-center justify-between px-3 pt-3">
<button type="button" onClick={prevLeft}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-white hover:bg-white/8 transition-all"
aria-label="Previous month"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M7 1L3 5l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<span className="text-sm font-semibold text-white">{MONTH_NAMES[lMonth]} {lYear}</span>
<div className="w-8" />
</div>
<MonthGrid year={lYear} month={lMonth} start={start} end={end} hover={hover}
onHover={setHover} onSelect={handleSelect} minDate={minDate} maxDate={maxDate} />
</div>
{/* Right month */}
<div className="flex-1">
<div className="flex items-center justify-between px-3 pt-3">
<div className="w-8" />
<span className="text-sm font-semibold text-white">{MONTH_NAMES[rMonth]} {rYear}</span>
<button type="button" onClick={nextLeft}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-white hover:bg-white/8 transition-all"
aria-label="Next month"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
<MonthGrid year={rYear} month={rMonth} start={start} end={end} hover={hover}
onHover={setHover} onSelect={handleSelect} minDate={minDate} maxDate={maxDate} />
</div>
</div>
{/* Footer with preset shortcuts */}
<div className="border-t border-white/8 px-4 py-2.5 flex items-center gap-3 flex-wrap">
{[
{ label: 'Last 7 days', fn: () => { const e=toISO(new Date()); const s=toISO(new Date(Date.now()-6*864e5)); onChange?.({start:s,end:e}); setOpen(false) } },
{ label: 'Last 30 days', fn: () => { const e=toISO(new Date()); const s=toISO(new Date(Date.now()-29*864e5)); onChange?.({start:s,end:e}); setOpen(false) } },
{ label: 'This month', fn: () => { const n=new Date(); const s=toISO(new Date(n.getFullYear(),n.getMonth(),1)); const e=toISO(new Date(n.getFullYear(),n.getMonth()+1,0)); onChange?.({start:s,end:e}); setOpen(false) } },
].map((p) => (
<button key={p.label} type="button" onClick={p.fn}
className="text-xs text-slate-400 hover:text-accent transition-colors font-medium"
>
{p.label}
</button>
))}
</div>
</div>,
document.body,
)}
</div>
)
}

View File

@@ -0,0 +1,40 @@
import React from 'react'
/**
* Nova FormField thin wrapper that pairs a label with any input-like child,
* plus optional hint and error text. Use this for custom controls (NovaSelect,
* Toggle, etc.) that don't carry their own label.
*
* @prop {string} label - visible label text
* @prop {boolean} required - shows red asterisk
* @prop {string} error - validation error message
* @prop {string} hint - helper text shown below control
* @prop {string} htmlFor - id of the labelled element
*/
export default function FormField({ label, required, error, hint, htmlFor, children, className = '' }) {
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
{label && (
<label
htmlFor={htmlFor}
className="text-sm font-medium text-white/85 select-none"
>
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
)}
{children}
{error && (
<p role="alert" className="text-xs text-red-400">
{error}
</p>
)}
{!error && hint && (
<p className="text-xs text-slate-500">{hint}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,121 @@
import React, { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
/**
* Nova Modal accessible dialog rendered in a portal.
*
* @prop {boolean} open - controls visibility
* @prop {function} onClose - called on backdrop click / Escape
* @prop {string} title - dialog header title
* @prop {React.ReactNode} footer - rendered in footer area
* @prop {string} size - 'sm' | 'md' | 'lg' | 'xl' | 'full'
* @prop {boolean} closeOnBackdrop - close when clicking outside (default true)
* @prop {string} variant - 'default' | 'danger'
*/
const sizeClass = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl':'max-w-2xl',
full: 'max-w-full h-full rounded-none',
}
export default function Modal({
open,
onClose,
title,
footer,
size = 'md',
closeOnBackdrop = true,
variant = 'default',
children,
className = '',
}) {
const panelRef = useRef(null)
// Lock scroll when open
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [open])
// Trap focus + handle Escape
useEffect(() => {
if (!open) return
const handleKey = (e) => {
if (e.key === 'Escape') onClose?.()
}
window.addEventListener('keydown', handleKey)
// Focus first focusable element
const firstFocusable = panelRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
firstFocusable?.focus()
return () => window.removeEventListener('keydown', handleKey)
}, [open, onClose])
if (!open) return null
const borderClass = variant === 'danger' ? 'border-red-500/30' : 'border-white/10'
const sClass = sizeClass[size] ?? sizeClass.md
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={closeOnBackdrop ? onClose : undefined}
aria-hidden="true"
/>
{/* Panel */}
<div
ref={panelRef}
className={[
'relative w-full bg-nova-900 rounded-2xl border shadow-2xl',
'flex flex-col max-h-[90vh]',
borderClass,
sClass,
className,
].join(' ')}
>
{/* Header */}
{title && (
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
<h2 className="text-base font-bold text-white">{title}</h2>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-white hover:bg-white/8 transition-all"
aria-label="Close dialog"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
)}
{/* Body */}
<div className="flex-1 overflow-y-auto nova-scrollbar px-6 py-5">
{children}
</div>
{/* Footer */}
{footer && (
<div className="shrink-0 px-6 py-4 border-t border-white/10 flex items-center justify-end gap-2">
{footer}
</div>
)}
</div>
</div>,
document.body,
)
}

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

View File

@@ -0,0 +1,93 @@
import React, { forwardRef } from 'react'
/**
* Nova Radio single choice radio button.
*
* Usage (as a group):
* {options.map(o => (
* <Radio key={o.value} value={o.value} checked={val===o.value} onChange={setVal} label={o.label} />
* ))}
*
* Or use RadioGroup for a pre-built grouped set.
*/
export const Radio = forwardRef(function Radio(
{ label, hint, size = 18, id, className = '', ...rest },
ref,
) {
const dim = typeof size === 'number' ? `${size}px` : size
const inputId = id ?? (label ? `radio-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined)
return (
<label className="inline-flex items-start gap-2.5 cursor-pointer select-none">
<input
type="radio"
id={inputId}
ref={ref}
className={[
'shrink-0 cursor-pointer',
'border border-white/25 bg-white/8',
'text-accent checked:bg-accent checked:border-accent',
'transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-0',
'disabled:opacity-50 disabled:cursor-not-allowed',
className,
].join(' ')}
style={{
width: dim,
height: dim,
minWidth: dim,
minHeight: dim,
aspectRatio: '1 / 1',
marginTop: label ? '1px' : undefined,
}}
{...rest}
/>
{(label || hint) && (
<span className="flex flex-col gap-0.5">
{label && <span className="text-sm text-white/90 leading-snug">{label}</span>}
{hint && <span className="text-xs text-slate-500">{hint}</span>}
</span>
)}
</label>
)
})
/**
* RadioGroup renders a set of Radio buttons from an options array.
*
* @prop {Array} options - [{ value, label, hint? }]
* @prop {string} value - currently selected value
* @prop {function} onChange - called with new value string
* @prop {string} name - unique name for radio group (required)
* @prop {string} label - group label
* @prop {string} error - validation error
* @prop {'vertical'|'horizontal'} direction
*/
export function RadioGroup({ options = [], value, onChange, name, label, error, direction = 'vertical', className = '' }) {
return (
<fieldset className={`flex flex-col gap-1.5 ${className}`}>
{label && (
<legend className="text-sm font-medium text-white/85 mb-1">{label}</legend>
)}
<div className={`flex gap-3 ${direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col'}`}>
{options.map((opt) => (
<Radio
key={opt.value}
name={name}
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
label={opt.label}
hint={opt.hint}
/>
))}
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
</fieldset>
)
}
export default Radio

View File

@@ -0,0 +1,87 @@
import React, { forwardRef } from 'react'
/**
* Nova Select styled native <select>
*
* Accepts the same options API as a plain <select>:
* - Pass children (<option>, <optgroup>) directly, OR
* - Pass `options` array of { value, label } and optional `placeholder`
*
* @prop {Array} options - [{ value, label }] optional shorthand
* @prop {string} placeholder - adds a blank first option when using `options`
* @prop {string} label - field label
* @prop {string} error - validation error
* @prop {string} hint - helper text
* @prop {boolean} required - asterisk on label
* @prop {string} size - 'sm' | 'md' | 'lg'
*/
const Select = forwardRef(function Select(
{ label, error, hint, required, options, placeholder, size = 'md', id, className = '', children, ...rest },
ref,
) {
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
const sizeClass = {
sm: 'py-1.5 text-xs',
md: 'py-2.5 text-sm',
lg: 'py-3 text-base',
}[size] ?? 'py-2.5 text-sm'
const inputClass = [
'block w-full rounded-xl border bg-white/[0.06] text-white',
'pl-3.5 pr-9',
'appearance-none cursor-pointer',
'bg-no-repeat bg-right',
'transition-all duration-150',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
error
? 'border-red-500/60 focus:border-red-500/70 focus:ring-red-500/40'
: 'border-white/12 hover:border-white/20 focus:border-accent/50 focus:ring-accent/40',
'disabled:opacity-50 disabled:cursor-not-allowed',
sizeClass,
className,
].join(' ')
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-white/85">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
)}
<div className="relative">
<select
id={inputId}
ref={ref}
className={inputClass}
aria-invalid={!!error}
{...rest}
>
{placeholder && <option value="" className="bg-nova-900">{placeholder}</option>}
{options
? options.map((o) => (
<option key={o.value} value={o.value} className="bg-nova-900 text-white">
{o.label}
</option>
))
: children}
</select>
{/* Custom chevron */}
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
</div>
)
})
export default Select

View File

@@ -0,0 +1,5 @@
/**
* @deprecated Use the unified Checkbox from Components/ui instead.
* This shim exists only for backward compatibility.
*/
export { default } from '../ui/Checkbox'

View File

@@ -0,0 +1,102 @@
import React, { forwardRef } from 'react'
/**
* Nova TextInput
*
* @prop {string} label - optional label above field
* @prop {string} error - validation error message
* @prop {string} hint - helper text below field
* @prop {React.ReactNode} leftIcon - icon/element to show inside left side
* @prop {React.ReactNode} rightIcon - icon/element to show inside right side
* @prop {boolean} required - shows red asterisk on label
* @prop {string} size - 'sm' | 'md' | 'lg'
*/
const TextInput = forwardRef(function TextInput(
{
label,
error,
hint,
leftIcon,
rightIcon,
required,
size = 'md',
id,
className = '',
...rest
},
ref,
) {
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
const sizeClass = {
sm: 'py-1.5 text-xs',
md: 'py-2.5 text-sm',
lg: 'py-3 text-base',
}[size] ?? 'py-2.5 text-sm'
const paddingLeft = leftIcon ? 'pl-10' : 'pl-3.5'
const paddingRight = rightIcon ? 'pr-10' : 'pr-3.5'
const inputClass = [
'block w-full rounded-xl border bg-white/[0.06] text-white',
'placeholder:text-slate-500',
'transition-all duration-150',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
error
? 'border-red-500/60 focus:border-red-500/70 focus:ring-red-500/40'
: 'border-white/12 hover:border-white/20 focus:border-accent/50 focus:ring-accent/40',
'disabled:opacity-50 disabled:cursor-not-allowed',
sizeClass,
paddingLeft,
paddingRight,
className,
].join(' ')
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-white/85">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
)}
<div className="relative">
{leftIcon && (
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
{leftIcon}
</span>
)}
<input
id={inputId}
ref={ref}
className={inputClass}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...rest}
/>
{rightIcon && (
<span className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-500">
{rightIcon}
</span>
)}
</div>
{error && (
<p id={`${inputId}-error`} role="alert" className="text-xs text-red-400">
{error}
</p>
)}
{!error && hint && (
<p id={`${inputId}-hint`} className="text-xs text-slate-500">
{hint}
</p>
)}
</div>
)
})
export default TextInput

View File

@@ -0,0 +1,65 @@
import React, { forwardRef } from 'react'
/**
* Nova Textarea
*
* @prop {string} label - optional label
* @prop {string} error - validation error
* @prop {string} hint - helper text
* @prop {boolean} required - red asterisk on label
* @prop {number} rows - visible rows (default 4)
* @prop {boolean} resize - allow manual resize (default false)
*/
const Textarea = forwardRef(function Textarea(
{ label, error, hint, required, rows = 4, resize = false, id, className = '', ...rest },
ref,
) {
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
const inputClass = [
'block w-full rounded-xl border bg-white/[0.06] text-white text-sm',
'px-3.5 py-2.5 placeholder:text-slate-500',
'transition-all duration-150',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
resize ? 'resize-y' : 'resize-none',
error
? 'border-red-500/60 focus:border-red-500/70 focus:ring-red-500/40'
: 'border-white/12 hover:border-white/20 focus:border-accent/50 focus:ring-accent/40',
'disabled:opacity-50 disabled:cursor-not-allowed',
className,
].join(' ')
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-white/85">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
)}
<textarea
id={inputId}
ref={ref}
rows={rows}
className={inputClass}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...rest}
/>
{error && (
<p id={`${inputId}-error`} role="alert" className="text-xs text-red-400">
{error}
</p>
)}
{!error && hint && (
<p id={`${inputId}-hint`} className="text-xs text-slate-500">
{hint}
</p>
)}
</div>
)
})
export default Textarea

View File

@@ -0,0 +1,84 @@
import React, { forwardRef } from 'react'
/**
* Nova Toggle on/off switch
*
* @prop {boolean} checked - controlled value
* @prop {function} onChange - change handler (receives event OR is called with no args if `simpleChange` true)
* @prop {string} label - text label beside the toggle
* @prop {string} hint - small helper text
* @prop {string} size - 'sm' | 'md' | 'lg'
* @prop {string} variant - 'accent' | 'emerald' | 'sky'
*/
const sizeMap = {
sm: { track: 'w-8 h-4', thumb: 'w-3 h-3', translate: 'translate-x-4', offset: 'translate-x-0.5' },
md: { track: 'w-10 h-5', thumb: 'w-3.5 h-3.5', translate: 'translate-x-5', offset: 'translate-x-[3px]' },
lg: { track: 'w-12 h-6', thumb: 'w-4.5 h-4.5', translate: 'translate-x-6', offset: 'translate-x-[3px]' },
}
const variantMap = {
accent: 'bg-accent',
emerald: 'bg-emerald-500',
sky: 'bg-sky-500',
}
const Toggle = forwardRef(function Toggle(
{ checked = false, onChange, label, hint, size = 'md', variant = 'accent', id, disabled = false, className = '' },
ref,
) {
const s = sizeMap[size] ?? sizeMap.md
const vClass = variantMap[variant] ?? variantMap.accent
const inputId = id ?? (label ? `toggle-${label.toLowerCase().replace(/\s+/g, '-')}` : undefined)
return (
<label
className={[
'inline-flex items-start gap-3 cursor-pointer select-none',
disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
className,
].join(' ')}
>
{/* Hidden native checkbox for a11y */}
<input
type="checkbox"
id={inputId}
ref={ref}
checked={checked}
onChange={onChange}
disabled={disabled}
className="sr-only"
role="switch"
aria-checked={checked}
/>
{/* Track */}
<span
className={[
'relative inline-flex shrink-0 rounded-full transition-colors duration-200 mt-px',
s.track,
checked ? vClass : 'bg-white/15',
'focus-within:ring-2 focus-within:ring-accent/50 focus-within:ring-offset-0',
].join(' ')}
aria-hidden="true"
>
{/* Thumb */}
<span
className={[
'absolute top-1/2 -translate-y-1/2 rounded-full bg-white shadow transition-transform duration-200',
s.thumb,
checked ? s.translate : s.offset,
].join(' ')}
/>
</span>
{(label || hint) && (
<span className="flex flex-col gap-0.5">
{label && <span className="text-sm text-white/90 leading-snug">{label}</span>}
{hint && <span className="text-xs text-slate-500">{hint}</span>}
</span>
)}
</label>
)
})
export default Toggle

View File

@@ -0,0 +1,22 @@
/**
* Nova UI Component Library
*
* Import from this barrel for all standard UI controls:
*
* import { Button, TextInput, NovaSelect, DatePicker, ... } from '@/Components/ui'
* // or with path alias:
* import { Button } from '../../Components/ui'
*/
export { default as Button } from './Button'
export { default as TextInput } from './TextInput'
export { default as Textarea } from './Textarea'
export { default as FormField } from './FormField'
export { default as Select } from './Select'
export { default as NovaSelect } from './NovaSelect'
export { default as Checkbox } from './Checkbox'
export { default as Radio, RadioGroup } from './Radio'
export { default as Toggle } from './Toggle'
export { default as DatePicker } from './DatePicker'
export { default as DateRangePicker } from './DateRangePicker'
export { default as Modal } from './Modal'

View File

@@ -1,5 +1,6 @@
import React from 'react'
import TagInput from '../tags/TagInput'
import Checkbox from '../../Components/ui/Checkbox'
export default function UploadSidebar({
title = 'Artwork details',
@@ -75,20 +76,17 @@ export default function UploadSidebar({
</section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<label className="flex items-start gap-3 text-sm text-white/90">
<input
id="upload-sidebar-rights"
type="checkbox"
checked={Boolean(metadata.rightsAccepted)}
onChange={(event) => onToggleRights?.(event.target.checked)}
className="mt-0.5 h-5 w-5 rounded-md border border-white/30 bg-slate-900/70 text-emerald-400 accent-emerald-500 focus:ring-2 focus:ring-emerald-400/40"
/>
<span>
I confirm I own the rights to this content. <span className="text-red-300">*</span>
<span className="mt-1 block text-xs text-white/60">Required before publishing.</span>
{errors.rights && <span className="mt-1 block text-xs text-red-200">{errors.rights}</span>}
</span>
</label>
<Checkbox
id="upload-sidebar-rights"
checked={Boolean(metadata.rightsAccepted)}
onChange={(event) => onToggleRights?.(event.target.checked)}
variant="emerald"
size={20}
label="I confirm I own the rights to this content."
hint="Required before publishing."
error={errors.rights}
required
/>
</section>
</div>
</aside>

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import TagInput from '../tags/TagInput'
import ScreenshotUploader from './ScreenshotUploader'
import Checkbox from '../../Components/ui/Checkbox'
const STEP_PRELOAD = 1
const STEP_DETAILS = 2
@@ -561,14 +562,14 @@ export default function UploadWizard({
/>
</div>
<label className="mt-7 inline-flex items-center gap-2 text-sm text-white/80">
<input
type="checkbox"
<div className="mt-7">
<Checkbox
checked={details.nsfw}
onChange={(event) => setDetails((prev) => ({ ...prev, nsfw: event.target.checked }))}
size={16}
label="NSFW"
/>
NSFW
</label>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">