Files
ScriptVPS/vps-monitor/frontend/src/components/ui/Modal.jsx
jeanotx32 f37b639226
All checks were successful
Build and Push Docker Images / docker (push) Successful in 42s
feat: enhance VPS management interface with search, filter, and toast notifications
- Added a DashboardToolbar component for searching, filtering, and sorting VPS instances.
- Implemented a ConfirmDialog component for destructive actions confirmation.
- Introduced a Modal component for displaying modals with focus management.
- Created a Toast component for displaying notifications with different types (success, error, info).
- Refactored VpsCard to utilize new Metric component for displaying CPU and RAM usage.
- Improved user experience with local storage for collapsed state in VpsCard.
- Added clipboard utility functions for copying text and downloading content.
- Enhanced CSS styles for better dark mode support and animations.
- Updated various UI controls for consistency and improved accessibility.
2026-08-01 01:22:45 -04:00

132 lines
4.1 KiB
JavaScript

import { useEffect, useId, useRef } from 'react'
import { X } from 'lucide-react'
const SIZES = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-3xl',
xl: 'max-w-4xl',
}
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
/**
* Modale de base : fermeture par Échap et clic sur le fond, piège à focus,
* restitution du focus à la fermeture, verrouillage du défilement, sémantique
* ARIA. Tous les modals de l'application passent par ici.
*/
export default function Modal({
title,
subtitle,
icon,
size = 'md',
onClose,
headerRight,
footer,
children,
bodyClassName = 'overflow-y-auto p-4',
panelClassName = '',
}) {
const panelRef = useRef(null)
const onCloseRef = useRef(onClose)
const titleId = useId()
// `onClose` est souvent une lambda recréée à chaque rendu du parent : on la
// lit via une ref pour que l'effet ne se rejoue pas (sinon le focus sauterait
// au premier champ à chaque rafraîchissement automatique).
useEffect(() => { onCloseRef.current = onClose })
useEffect(() => {
const previouslyFocused = document.activeElement
const panel = panelRef.current
const firstFocusable = panel?.querySelector(FOCUSABLE)
;(firstFocusable ?? panel)?.focus({ preventScroll: true })
const onKeyDown = (e) => {
if (e.key === 'Escape') {
e.stopPropagation()
onCloseRef.current?.()
return
}
if (e.key !== 'Tab' || !panel) return
const items = [...panel.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null)
if (items.length === 0) return
const first = items[0]
const last = items[items.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
previouslyFocused?.focus?.({ preventScroll: true })
}
}, [])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={`w-full ${SIZES[size] ?? SIZES.md} flex flex-col max-h-[88vh] bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl outline-none animate-modal-in ${panelClassName}`}
>
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-800 flex-shrink-0">
<div className="flex items-center gap-2.5 min-w-0">
{icon}
<div className="min-w-0">
<h2 id={titleId} className="font-semibold text-sm truncate">{title}</h2>
{subtitle && <p className="text-xs text-gray-500 truncate mt-0.5">{subtitle}</p>}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{headerRight}
<button
onClick={onClose}
aria-label="Fermer"
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* `min-h-0` autorise l'enfant à défiler dans un conteneur flex */}
<div className={`flex-1 min-h-0 ${bodyClassName}`}>
{children}
</div>
{footer && (
<div className="px-4 py-3 border-t border-gray-800 flex-shrink-0">
{footer}
</div>
)}
</div>
</div>
)
}