- 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
122 lines
3.6 KiB
JavaScript
122 lines
3.6 KiB
JavaScript
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,
|
||
)
|
||
}
|