From a87520348216b9b5cbef1e0aff9e5c95ccbc8228 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 1 Mar 2026 10:41:43 +0100 Subject: [PATCH] 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 + 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. - + {state.cancelledAt && (
diff --git a/resources/js/components/Studio/BulkActionsBar.jsx b/resources/js/components/Studio/BulkActionsBar.jsx index f0445ec2..83fa4917 100644 --- a/resources/js/components/Studio/BulkActionsBar.jsx +++ b/resources/js/components/Studio/BulkActionsBar.jsx @@ -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 }) {
- +
+ ({ value: a.value, label: a.label }))} + value={action || null} + onChange={(val) => setAction(val ?? '')} + placeholder="Choose action…" + searchable={false} + /> +
{/* Thumbnail */}
diff --git a/resources/js/components/Studio/StudioTable.jsx b/resources/js/components/Studio/StudioTable.jsx index d3f86c8f..6500a755 100644 --- a/resources/js/components/Studio/StudioTable.jsx +++ b/resources/js/components/Studio/StudioTable.jsx @@ -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 - @@ -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]'}`} > - 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}`} /> diff --git a/resources/js/components/Studio/StudioToolbar.jsx b/resources/js/components/Studio/StudioToolbar.jsx index d9f6da56..f2962e55 100644 --- a/resources/js/components/Studio/StudioToolbar.jsx +++ b/resources/js/components/Studio/StudioToolbar.jsx @@ -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({
{/* Sort */} - +
+ +
{/* Filter toggle */} + ) +} diff --git a/resources/js/components/ui/Checkbox.jsx b/resources/js/components/ui/Checkbox.jsx new file mode 100644 index 00000000..85725b50 --- /dev/null +++ b/resources/js/components/ui/Checkbox.jsx @@ -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 ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ) +}) + +export default Checkbox diff --git a/resources/js/components/ui/DatePicker.jsx b/resources/js/components/ui/DatePicker.jsx new file mode 100644 index 00000000..05e4f721 --- /dev/null +++ b/resources/js/components/ui/DatePicker.jsx @@ -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 ( +
+ {/* Day-of-week headers */} +
+ {DAY_ABBR.map((d) => ( +
{d}
+ ))} +
+ + {/* Day cells */} +
+ {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 ( + + ) + })} +
+
+ ) +} + +/* ─── 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 ( +
+ {label && ( + + )} + +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }} + role="button" aria-label={label ?? placeholder} id={inputId} + > + {/* Calendar icon */} + + + + {value ? formatDisplay(value) : placeholder} + + + {clearable && value && ( + + )} +
+ + {error &&

{error}

} + {!error && hint &&

{hint}

} + + {open && createPortal( +
+ {/* Month nav header */} +
+ + + + {MONTH_NAMES[viewMonth]} {viewYear} + + + +
+ + + + {/* Today shortcut */} +
+ +
+
, + document.body, + )} +
+ ) +} diff --git a/resources/js/components/ui/DateRangePicker.jsx b/resources/js/components/ui/DateRangePicker.jsx new file mode 100644 index 00000000..966fe434 --- /dev/null +++ b/resources/js/components/ui/DateRangePicker.jsx @@ -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 ( +
+
+ {DAY_ABBR.map((d) => ( +
{d}
+ ))} +
+
+ {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 ( + + ) + })} +
+
+ ) +} + +/* ─── 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 ( +
+ {label && ( + + )} + +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }} + role="button" aria-label={label ?? placeholder} id={inputId} + > + {/* Calendar icon */} + + + + {displayText || placeholder} + + + {clearable && (start || end) && ( + + )} +
+ + {error &&

{error}

} + {!error && hint &&

{hint}

} + + {open && createPortal( +
setHover('')} + > + {/* Header info */} + {picking === 'end' && ( +
+ Now click an end date +
+ )} + + {/* Two calendars */} +
+ {/* Left month */} +
+
+ + {MONTH_NAMES[lMonth]} {lYear} +
+
+ +
+ + {/* Right month */} +
+
+
+ {MONTH_NAMES[rMonth]} {rYear} + +
+ +
+
+ + {/* Footer with preset shortcuts */} +
+ {[ + { 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) => ( + + ))} +
+
, + document.body, + )} +
+ ) +} diff --git a/resources/js/components/ui/FormField.jsx b/resources/js/components/ui/FormField.jsx new file mode 100644 index 00000000..965e3909 --- /dev/null +++ b/resources/js/components/ui/FormField.jsx @@ -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 ( +
+ {label && ( + + )} + + {children} + + {error && ( +

+ {error} +

+ )} + + {!error && hint && ( +

{hint}

+ )} +
+ ) +} diff --git a/resources/js/components/ui/Modal.jsx b/resources/js/components/ui/Modal.jsx new file mode 100644 index 00000000..9e927a30 --- /dev/null +++ b/resources/js/components/ui/Modal.jsx @@ -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( +
+ {/* Backdrop */} + , + document.body, + ) +} diff --git a/resources/js/components/ui/NovaSelect.jsx b/resources/js/components/ui/NovaSelect.jsx new file mode 100644 index 00000000..013c94db --- /dev/null +++ b/resources/js/components/ui/NovaSelect.jsx @@ -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 ( +
+ {label && ( + + )} + + {/* Trigger button */} +
+ {/* Tags (multi) or selected label (single) */} +
+ {multi && selected.map((v) => ( + + {labelMap[String(v)] ?? v} + + + ))} + + {!multi && hasValue && ( + {labelMap[String(selected[0])] ?? selected[0]} + )} + + {!hasValue && ( + {placeholder} + )} +
+ + {/* Right icons */} +
+ {clearable && hasValue && ( + + )} + +
+
+ + {error &&

{error}

} + {!error && hint &&

{hint}

} + + {/* Dropdown portal */} + {open && createPortal( +
+ {/* Search */} + {searchable && ( +
+
+ + { 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" + /> +
+
+ )} + + {/* Options */} +
+ {filtered.length === 0 ? ( +

No options found

+ ) : ( + 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 ( + + {showGroupHeader && ( +
0 ? ' border-t border-white/5' : ''}`}> + {opt.group} +
+ )} +
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 && ( + + {isSelected && ( + + )} + + )} + + {opt.icon && {opt.icon}} + + {renderOption ? renderOption(opt) : ( + {opt.label} + )} + + {/* Tick in single mode */} + {!multi && isSelected && ( + + )} +
+
+ ) + }) + )} +
+
, + document.body, + )} +
+ ) +} diff --git a/resources/js/components/ui/Radio.jsx b/resources/js/components/ui/Radio.jsx new file mode 100644 index 00000000..9bbf6c5a --- /dev/null +++ b/resources/js/components/ui/Radio.jsx @@ -0,0 +1,93 @@ +import React, { forwardRef } from 'react' + +/** + * Nova Radio – single choice radio button. + * + * Usage (as a group): + * {options.map(o => ( + * + * ))} + * + * 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 ( + + ) +}) + +/** + * 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 ( +
+ {label && ( + {label} + )} + +
+ {options.map((opt) => ( + onChange(opt.value)} + label={opt.label} + hint={opt.hint} + /> + ))} +
+ + {error &&

{error}

} +
+ ) +} + +export default Radio diff --git a/resources/js/components/ui/Select.jsx b/resources/js/components/ui/Select.jsx new file mode 100644 index 00000000..7467ea6c --- /dev/null +++ b/resources/js/components/ui/Select.jsx @@ -0,0 +1,87 @@ +import React, { forwardRef } from 'react' + +/** + * Nova Select – styled native : + * - Pass children () 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 ( +
+ {label && ( + + )} + +
+ + + {/* Custom chevron */} + + + +
+ + {error &&

{error}

} + {!error && hint &&

{hint}

} +
+ ) +}) + +export default Select diff --git a/resources/js/components/ui/SquareCheckbox.jsx b/resources/js/components/ui/SquareCheckbox.jsx new file mode 100644 index 00000000..4d29e70a --- /dev/null +++ b/resources/js/components/ui/SquareCheckbox.jsx @@ -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' diff --git a/resources/js/components/ui/TextInput.jsx b/resources/js/components/ui/TextInput.jsx new file mode 100644 index 00000000..917cd1ef --- /dev/null +++ b/resources/js/components/ui/TextInput.jsx @@ -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 ( +
+ {label && ( + + )} + +
+ {leftIcon && ( + + {leftIcon} + + )} + + + + {rightIcon && ( + + {rightIcon} + + )} +
+ + {error && ( + + )} + + {!error && hint && ( +

+ {hint} +

+ )} +
+ ) +}) + +export default TextInput diff --git a/resources/js/components/ui/Textarea.jsx b/resources/js/components/ui/Textarea.jsx new file mode 100644 index 00000000..faa12531 --- /dev/null +++ b/resources/js/components/ui/Textarea.jsx @@ -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 ( +
+ {label && ( + + )} + +