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:
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user