espace-paie-odentas/components/ui/tooltip.tsx
odentas 73e914a303 feat: Système de statuts enrichi avec descriptions markdown et refonte navigation
- Header: Ajout 3ème ligne de statut (Caisses & orga.) avec descriptions détaillées
- Tooltips: Affichage riche avec titre, voyant coloré et contenu markdown formaté
- Éditeur markdown: Nouveau composant RichTextEditor avec toolbar (gras, italique, liens, listes)
- Modal staff: StatusEditModal étendu avec onglets et éditeur de descriptions
- Migration: Ajout colonnes status_*_description dans maintenance_status
- API: Routes GET/PUT enrichies pour gérer les 9 champs de statut
- Navigation: Redirection /compte/securite → /securite (nouvelle page centralisée)
- Breadcrumb: Support contrats RG/CDDU multi + labels dynamiques salariés
- UX Documents: Bouton 'Nouvel onglet / Télécharger' au lieu de téléchargement forcé
- Contrats staff: Pagination paies (6/page) pour RG et CDDU multi-mois avec vue compacte
- PayslipCard: Bouton cliquable 'Ouvrir le PDF' pour accès direct aux bulletins
2025-10-31 19:42:25 +01:00

122 lines
No EOL
3.8 KiB
TypeScript

"use client"
import * as React from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
interface TooltipProps {
children: React.ReactNode
content: string
className?: string
side?: 'top' | 'bottom' | 'left' | 'right'
asChild?: boolean
allowHTML?: boolean
}
export function Tooltip({ children, content, className, side = 'top', asChild = false, allowHTML = false }: TooltipProps) {
const [isVisible, setIsVisible] = React.useState(false)
const [triggerRect, setTriggerRect] = React.useState<DOMRect | null>(null)
const triggerRef = React.useRef<HTMLDivElement>(null)
const updatePosition = () => {
if (triggerRef.current) {
setTriggerRect(triggerRef.current.getBoundingClientRect())
}
}
const handleMouseEnter = () => {
updatePosition()
setIsVisible(true)
}
const handleMouseLeave = () => {
setIsVisible(false)
}
const getTooltipStyle = (): React.CSSProperties => {
if (!triggerRect) return {}
const offset = 8
let left = 0
let top = 0
switch (side) {
case 'top':
left = triggerRect.left + triggerRect.width / 2
top = triggerRect.top - offset
break
case 'bottom':
left = triggerRect.left + triggerRect.width / 2
top = triggerRect.bottom + offset
break
case 'left':
left = triggerRect.left - offset
top = triggerRect.top + triggerRect.height / 2
break
case 'right':
left = triggerRect.right + offset
top = triggerRect.top + triggerRect.height / 2
break
}
// Position the tooltip relative to the computed anchor point
// For 'top', anchor is at the bottom-center of the tooltip (so translateY(-100%))
// For 'bottom', anchor is at the top-center (no vertical translation)
// For 'left'/'right', anchor centered vertically.
let transform = ''
if (side === 'top') transform = 'translate(-50%, -100%)'
else if (side === 'bottom') transform = 'translate(-50%, 0)'
else if (side === 'left') transform = 'translate(-100%, -50%)'
else transform = 'translate(0, -50%)'
return {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
transform,
zIndex: 9999
}
}
const arrowClasses = {
top: 'top-full left-1/2 transform -translate-x-1/2 border-t-white border-t-4 border-x-transparent border-x-4 border-b-0',
bottom: 'bottom-full left-1/2 transform -translate-x-1/2 border-b-white border-b-4 border-x-transparent border-x-4 border-t-0',
left: 'left-full top-1/2 transform -translate-y-1/2 border-l-white border-l-4 border-y-transparent border-y-4 border-r-0',
right: 'right-full top-1/2 transform -translate-y-1/2 border-r-white border-r-4 border-y-transparent border-y-4 border-l-0'
}
return (
<>
<div
ref={triggerRef}
className={asChild ? "block w-full" : "inline-block"}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleMouseEnter}
onBlur={handleMouseLeave}
>
{children}
</div>
{isVisible && triggerRect && typeof window !== 'undefined' && createPortal(
<div
style={getTooltipStyle()}
className={cn(
"px-4 py-3 text-sm rounded-xl shadow-2xl pointer-events-none relative max-w-md min-w-[280px]",
"bg-white text-slate-900 border border-slate-200",
"animate-in fade-in-0 zoom-in-95 duration-200",
className
)}
>
{allowHTML ? (
<div dangerouslySetInnerHTML={{ __html: content }} className="prose prose-sm max-w-none" />
) : (
content
)}
<div className={cn("absolute w-0 h-0", arrowClasses[side])} />
</div>,
document.body
)}
</>
)
}