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,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