All checks were successful
Build and Push Docker Images / docker (push) Successful in 42s
- 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.
132 lines
4.1 KiB
JavaScript
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>
|
|
)
|
|
}
|