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:
@@ -63,3 +63,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.nova-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.14) transparent;
|
||||
}
|
||||
.nova-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
.nova-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.nova-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255,255,255,0.14);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.nova-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255,255,255,0.28);
|
||||
}
|
||||
.nova-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
5
resources/js/components/Studio/StudioCheckbox.jsx
Normal file
5
resources/js/components/Studio/StudioCheckbox.jsx
Normal 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'
|
||||
@@ -1,14 +1,14 @@
|
||||
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' },
|
||||
]
|
||||
|
||||
const performanceOptions = [
|
||||
{ value: '', label: 'All performance' },
|
||||
{ value: 'rising', label: 'Rising (hot)' },
|
||||
{ value: 'top', label: 'Top performers' },
|
||||
{ value: 'low', label: 'Low performers' },
|
||||
@@ -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"> {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"
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Clear */}
|
||||
<button
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
<div className="min-w-[160px]">
|
||||
<NovaSelect
|
||||
options={sortOptions}
|
||||
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>
|
||||
onChange={onSortChange}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
|
||||
83
resources/js/components/ui/Button.jsx
Normal file
83
resources/js/components/ui/Button.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
resources/js/components/ui/Checkbox.jsx
Normal file
113
resources/js/components/ui/Checkbox.jsx
Normal 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
|
||||
341
resources/js/components/ui/DatePicker.jsx
Normal file
341
resources/js/components/ui/DatePicker.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
351
resources/js/components/ui/DateRangePicker.jsx
Normal file
351
resources/js/components/ui/DateRangePicker.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
resources/js/components/ui/FormField.jsx
Normal file
40
resources/js/components/ui/FormField.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
resources/js/components/ui/Modal.jsx
Normal file
121
resources/js/components/ui/Modal.jsx
Normal 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,
|
||||
)
|
||||
}
|
||||
415
resources/js/components/ui/NovaSelect.jsx
Normal file
415
resources/js/components/ui/NovaSelect.jsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/**
|
||||
* Nova NovaSelect – Select2-style dropdown
|
||||
*
|
||||
* Options format: [{ value, label, icon?, disabled?, group? }]
|
||||
*
|
||||
* @prop {Array} options - list of option objects
|
||||
* @prop {*} value - selected value (or array of values in multi mode)
|
||||
* @prop {function} onChange - called with new value (or array in multi mode)
|
||||
* @prop {boolean} multi - allow multiple selections
|
||||
* @prop {string} placeholder - placeholder text
|
||||
* @prop {boolean} searchable - filter options by typing (default true)
|
||||
* @prop {boolean} clearable - show clear button when a value is selected
|
||||
* @prop {string} label - label above the trigger
|
||||
* @prop {string} error - validation error
|
||||
* @prop {string} hint - helper text
|
||||
* @prop {boolean} required - asterisk on label
|
||||
* @prop {boolean} disabled
|
||||
* @prop {function} renderOption - custom render fn: (option) => ReactNode
|
||||
*/
|
||||
export default function NovaSelect({
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
multi = false,
|
||||
placeholder = 'Select…',
|
||||
searchable = true,
|
||||
clearable = false,
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
required = false,
|
||||
disabled = false,
|
||||
renderOption,
|
||||
id,
|
||||
className = '',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [highlighted, setHigh] = useState(-1)
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 300, openUp: false })
|
||||
|
||||
const triggerRef = useRef(null)
|
||||
const searchRef = useRef(null)
|
||||
const listRef = useRef(null)
|
||||
const inputId = id ?? (label ? `nova-select-${label.toLowerCase().replace(/\s+/g, '-')}` : 'nova-select')
|
||||
|
||||
// Normalize value to array internally
|
||||
const selected = useMemo(() => {
|
||||
if (multi) return Array.isArray(value) ? value : (value != null ? [value] : [])
|
||||
return value != null ? [value] : []
|
||||
}, [value, multi])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected.map(String)), [selected])
|
||||
|
||||
// Filtered + grouped options
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
return options.filter((o) => !q || o.label.toLowerCase().includes(q))
|
||||
}, [options, search])
|
||||
|
||||
// Compute dropdown position from trigger bounding rect
|
||||
const measurePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
const dropH = Math.min(280, filtered.length * 38 + 52) // approx
|
||||
const openUp = spaceBelow < dropH + 8 && spaceAbove > spaceBelow
|
||||
|
||||
setDropPos({
|
||||
top: openUp ? rect.top - dropH - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
openUp,
|
||||
})
|
||||
}, [filtered.length])
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
if (disabled) return
|
||||
measurePosition()
|
||||
setOpen(true)
|
||||
setHigh(-1)
|
||||
}, [disabled, measurePosition])
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
setHigh(-1)
|
||||
}, [])
|
||||
|
||||
// Focus search when opened
|
||||
useLayoutEffect(() => {
|
||||
if (open && searchable) {
|
||||
setTimeout(() => searchRef.current?.focus(), 0)
|
||||
}
|
||||
}, [open, searchable])
|
||||
|
||||
// Close dropdown when scrolling outside it (prevents portal drifting from trigger)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onScroll = (e) => {
|
||||
const dropdown = document.getElementById(`nova-select-dropdown-${inputId}`)
|
||||
if (dropdown && dropdown.contains(e.target)) return // scrolling inside list — keep open
|
||||
closeDropdown()
|
||||
}
|
||||
const onResize = () => closeDropdown()
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
}, [open, closeDropdown, inputId])
|
||||
|
||||
// Click outside
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target) &&
|
||||
!document.getElementById(`nova-select-dropdown-${inputId}`)?.contains(e.target)
|
||||
) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open, closeDropdown, inputId])
|
||||
|
||||
// Scroll highlighted item into view
|
||||
useEffect(() => {
|
||||
if (highlighted < 0 || !listRef.current) return
|
||||
const item = listRef.current.querySelectorAll('[data-option]')[highlighted]
|
||||
item?.scrollIntoView({ block: 'nearest' })
|
||||
}, [highlighted])
|
||||
|
||||
const selectOption = useCallback((opt) => {
|
||||
if (opt.disabled) return
|
||||
if (multi) {
|
||||
const exists = selectedSet.has(String(opt.value))
|
||||
onChange(exists ? selected.filter((v) => String(v) !== String(opt.value)) : [...selected, opt.value])
|
||||
setSearch('')
|
||||
searchRef.current?.focus()
|
||||
} else {
|
||||
onChange(opt.value)
|
||||
closeDropdown()
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
}, [multi, selected, selectedSet, onChange, closeDropdown])
|
||||
|
||||
const clearValue = useCallback((e) => {
|
||||
e.stopPropagation()
|
||||
onChange(multi ? [] : null)
|
||||
}, [multi, onChange])
|
||||
|
||||
const removeTag = useCallback((val, e) => {
|
||||
e.stopPropagation()
|
||||
onChange(selected.filter((v) => String(v) !== String(val)))
|
||||
}, [selected, onChange])
|
||||
|
||||
// Keyboard handler on search/trigger
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (!open) {
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
openDropdown()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setHigh((h) => Math.min(h + 1, filtered.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setHigh((h) => Math.max(h - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (highlighted >= 0 && filtered[highlighted]) selectOption(filtered[highlighted])
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
closeDropdown()
|
||||
triggerRef.current?.focus()
|
||||
break
|
||||
case 'Backspace':
|
||||
if (multi && !search && selected.length > 0) {
|
||||
onChange(selected.slice(0, -1))
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [open, filtered, highlighted, search, multi, selected, selectOption, closeDropdown, openDropdown, onChange])
|
||||
|
||||
// Build display label(s)
|
||||
const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options])
|
||||
|
||||
const hasValue = selected.length > 0
|
||||
|
||||
// Trigger appearance
|
||||
const triggerClass = [
|
||||
'relative w-full flex items-center min-h-[42px] rounded-xl border px-3 py-1.5 gap-2 cursor-pointer',
|
||||
'bg-white/[0.06] text-sm text-white transition-all duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
|
||||
error
|
||||
? 'border-red-500/60 focus-visible:ring-red-500/40'
|
||||
: open
|
||||
? 'border-accent/50 ring-2 ring-accent/40'
|
||||
: 'border-white/12 hover:border-white/22',
|
||||
disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
|
||||
className,
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-sm font-medium text-white/85 select-none">
|
||||
{label}{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Trigger button */}
|
||||
<div
|
||||
ref={triggerRef}
|
||||
id={inputId}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={label ?? placeholder}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={triggerClass}
|
||||
onClick={open ? closeDropdown : openDropdown}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Tags (multi) or selected label (single) */}
|
||||
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
|
||||
{multi && selected.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-1 h-6 px-2 rounded-md bg-accent/20 text-accent text-xs font-medium"
|
||||
>
|
||||
{labelMap[String(v)] ?? v}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => removeTag(v, e)}
|
||||
className="hover:text-white transition-colors"
|
||||
aria-label={`Remove ${labelMap[String(v)] ?? v}`}
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 1l6 6M7 1L1 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{!multi && hasValue && (
|
||||
<span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
|
||||
)}
|
||||
|
||||
{!hasValue && (
|
||||
<span className="text-slate-500 truncate">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right icons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{clearable && hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={clearValue}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:text-white transition-colors"
|
||||
aria-label="Clear"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={`text-slate-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
|
||||
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
|
||||
|
||||
{/* Dropdown portal */}
|
||||
{open && createPortal(
|
||||
<div
|
||||
id={`nova-select-dropdown-${inputId}`}
|
||||
role="listbox"
|
||||
aria-multiselectable={multi}
|
||||
className="fixed z-[500] flex flex-col rounded-xl border border-white/12 bg-nova-900/80 backdrop-blur-xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width, maxHeight: 280 }}
|
||||
>
|
||||
{/* Search */}
|
||||
{searchable && (
|
||||
<div className="px-2 pt-2 pb-1 border-b border-white/8">
|
||||
<div className="relative">
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="5" cy="5" r="3.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M8 8l2.5 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setHigh(0) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search…"
|
||||
className="w-full pl-3 pr-7 py-1.5 rounded-lg bg-white/5 border border-white/8 text-white text-xs placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent/50"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div ref={listRef} className="overflow-y-auto flex-1 nova-scrollbar" style={{ maxHeight: 220 }}>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs text-slate-500">No options found</p>
|
||||
) : (
|
||||
filtered.map((opt, idx) => {
|
||||
const isSelected = selectedSet.has(String(opt.value))
|
||||
const isHighlighted = idx === highlighted
|
||||
const prevGroup = idx > 0 ? filtered[idx - 1].group : undefined
|
||||
const showGroupHeader = opt.group != null && opt.group !== prevGroup
|
||||
|
||||
return (
|
||||
<React.Fragment key={String(opt.value)}>
|
||||
{showGroupHeader && (
|
||||
<div className={`px-3 pt-2 pb-0.5 text-[10px] font-semibold text-slate-500 uppercase tracking-widest select-none${idx > 0 ? ' border-t border-white/5' : ''}`}>
|
||||
{opt.group}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
data-option
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={opt.disabled}
|
||||
onClick={() => selectOption(opt)}
|
||||
onMouseEnter={() => setHigh(idx)}
|
||||
className={[
|
||||
'flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer transition-colors duration-75',
|
||||
opt.disabled ? 'opacity-40 cursor-not-allowed' : '',
|
||||
isHighlighted ? 'bg-white/[0.13]' : 'hover:bg-white/[0.07]',
|
||||
isSelected ? 'text-accent' : 'text-white/85',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Checkmark in multi mode */}
|
||||
{multi && (
|
||||
<span className={[
|
||||
'w-4 h-4 shrink-0 rounded border flex items-center justify-center transition-colors',
|
||||
isSelected ? 'bg-accent border-accent' : 'border-white/25 bg-white/5',
|
||||
].join(' ')}>
|
||||
{isSelected && (
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 4.5l2 2 4-4" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{opt.icon && <span className="shrink-0">{opt.icon}</span>}
|
||||
|
||||
{renderOption ? renderOption(opt) : (
|
||||
<span className="flex-1 truncate">{opt.label}</span>
|
||||
)}
|
||||
|
||||
{/* Tick in single mode */}
|
||||
{!multi && isSelected && (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="text-accent shrink-0" aria-hidden="true">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
resources/js/components/ui/Radio.jsx
Normal file
93
resources/js/components/ui/Radio.jsx
Normal 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
|
||||
87
resources/js/components/ui/Select.jsx
Normal file
87
resources/js/components/ui/Select.jsx
Normal 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
|
||||
5
resources/js/components/ui/SquareCheckbox.jsx
Normal file
5
resources/js/components/ui/SquareCheckbox.jsx
Normal 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'
|
||||
102
resources/js/components/ui/TextInput.jsx
Normal file
102
resources/js/components/ui/TextInput.jsx
Normal 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
|
||||
65
resources/js/components/ui/Textarea.jsx
Normal file
65
resources/js/components/ui/Textarea.jsx
Normal 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
|
||||
84
resources/js/components/ui/Toggle.jsx
Normal file
84
resources/js/components/ui/Toggle.jsx
Normal 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
|
||||
22
resources/js/components/ui/index.js
Normal file
22
resources/js/components/ui/index.js
Normal 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'
|
||||
@@ -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
|
||||
<Checkbox
|
||||
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"
|
||||
variant="emerald"
|
||||
size={20}
|
||||
label="I confirm I own the rights to this content."
|
||||
hint="Required before publishing."
|
||||
error={errors.rights}
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -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">
|
||||
|
||||
23
sync.sh
Normal file
23
sync.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
localFolder='/mnt/d/Sites/Skinbase26/'
|
||||
remoteFolder='/opt/www/virtual/SkinbaseNova/'
|
||||
remoteServer='klevze@nastja.klevze.si'
|
||||
|
||||
rsync -avz \
|
||||
--chmod=D755,F644 \
|
||||
--exclude ".phpintel/" \
|
||||
--exclude "bootstrap/cache/" \
|
||||
--exclude ".env" \
|
||||
--exclude "public/hot" \
|
||||
--exclude "node_modules" \
|
||||
--exclude "public/files/" \
|
||||
--exclude "resources/lang/" \
|
||||
--exclude "storage/" \
|
||||
--exclude ".git/" \
|
||||
--exclude ".venv/" \
|
||||
--exclude "/oldSite" \
|
||||
--exclude "/vendor" \
|
||||
-e ssh \
|
||||
$localFolder \
|
||||
$remoteServer:$remoteFolder/
|
||||
Reference in New Issue
Block a user