Files
SkinbaseNova/resources/js/components/ui/Modal.jsx
Gregor Klevze a875203482 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
2026-03-01 10:41:43 +01:00

122 lines
3.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)
}