Fix dropdown menu: use React portal to render outside table overflow
CI / Lint & Format (push) Failing after 8s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 7s
CI / Docker Build (push) Has been skipped

- createPortal renders menu at document.body level, bypassing all
  parent overflow/scroll containers
- Fixed positioning calculated from button's getBoundingClientRect()
- menuButtonRefs map ensures correct position per row
This commit is contained in:
2026-06-25 22:13:11 -04:00
parent 009e42b3e2
commit 317c73407e
+52 -30
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PageLayout } from '@/components/layout/PageLayout'; import { PageLayout } from '@/components/layout/PageLayout';
import { PlanBadge } from '@/components/shared/PlanBadge'; import { PlanBadge } from '@/components/shared/PlanBadge';
@@ -27,6 +28,8 @@ export default function Clients() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [activeMenu, setActiveMenu] = useState<string | null>(null); const [activeMenu, setActiveMenu] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null);
const menuButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const [newApiKey, setNewApiKey] = useState<string | null>(null); const [newApiKey, setNewApiKey] = useState<string | null>(null);
const [keyCopied, setKeyCopied] = useState(false); const [keyCopied, setKeyCopied] = useState(false);
@@ -203,35 +206,54 @@ export default function Clients() {
</span> </span>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<div className="relative"> <button
<button ref={(el) => { if (el) menuButtonRefs.current.set(client.id, el); }}
onClick={() => setActiveMenu(activeMenu === client.id ? null : client.id)} onClick={() => {
className="p-1.5 rounded hover:bg-surface-overlay text-content-muted" const isOpen = activeMenu === client.id;
aria-label="Actions" if (isOpen) {
> setActiveMenu(null);
<MoreVertical size={16} /> setMenuPosition(null);
</button> } else {
{activeMenu === client.id && ( const btn = menuButtonRefs.current.get(client.id);
<> if (btn) {
<div className="fixed inset-0 z-10" onClick={() => setActiveMenu(null)} /> const rect = btn.getBoundingClientRect();
<div className="absolute right-0 top-8 z-20 bg-surface-raised border border-border rounded-lg shadow-xl py-1 w-48 animate-slide-in"> setMenuPosition({
<Link to={`/admin/clients/${client.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary" onClick={() => setActiveMenu(null)}> top: rect.bottom + 4,
<Eye size={14} /> Voir détail right: window.innerWidth - rect.right,
</Link> });
<Link to={`/gallery/client/${client.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary" onClick={() => setActiveMenu(null)}> }
<Images size={14} /> Voir galerie setActiveMenu(client.id);
</Link> }
<button onClick={() => { handleRotateKey(client.id); }} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary w-full text-left"> }}
<KeyRound size={14} /> Régénérer clé className="p-1.5 rounded hover:bg-surface-overlay text-content-muted"
</button> aria-label="Actions"
<hr className="my-1 border-border" /> >
<button onClick={() => { setConfirmDelete(client.id); setActiveMenu(null); }} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-status-error/10 text-status-error w-full text-left"> <MoreVertical size={16} />
<UserMinus size={14} /> Désactiver </button>
</button> {activeMenu === client.id && menuPosition && createPortal(
</div> <>
</> <div className="fixed inset-0 z-10" onClick={() => { setActiveMenu(null); setMenuPosition(null); }} />
)} <div
</div> className="fixed z-20 bg-surface-raised border border-border rounded-lg shadow-xl py-1 w-48 animate-slide-in"
style={{ top: menuPosition.top, right: menuPosition.right }}
>
<Link to={`/admin/clients/${client.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary" onClick={() => { setActiveMenu(null); setMenuPosition(null); }}>
<Eye size={14} /> Voir détail
</Link>
<Link to={`/gallery/client/${client.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary" onClick={() => { setActiveMenu(null); setMenuPosition(null); }}>
<Images size={14} /> Voir galerie
</Link>
<button onClick={() => { handleRotateKey(client.id); }} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-overlay text-content-secondary w-full text-left">
<KeyRound size={14} /> Régénérer clé
</button>
<hr className="my-1 border-border" />
<button onClick={() => { setConfirmDelete(client.id); setActiveMenu(null); setMenuPosition(null); }} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-status-error/10 text-status-error w-full text-left">
<UserMinus size={14} /> Désactiver
</button>
</div>
</>,
document.body
)}
</td> </td>
</tr> </tr>
))} ))}