import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; interface Props { /** Bouton « … » qui a ouvert le menu : le menu s'aligne sur lui. */ anchor: HTMLElement; onClose: () => void; children: ReactNode; } const MARGIN = 8; // écart minimal avec les bords de la fenêtre /** * Menu déroulant des actions d'un élément, ouvert sous le bouton « … » et aligné * sur son bord droit (au-dessus s'il manque de place en bas). Rendu dans * pour ne pas être rogné par le défilement de la liste ; refermé par un clic * ailleurs, Échap, un défilement, un redimensionnement ou le choix d'une action. */ export function ActionMenu({ anchor, onClose, children }: Props) { const ref = useRef(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); useLayoutEffect(() => { const menu = ref.current; if (!menu) return; const a = anchor.getBoundingClientRect(); const { offsetWidth: w, offsetHeight: h } = menu; let top = a.bottom + 4; if (top + h > innerHeight - MARGIN) top = Math.max(MARGIN, a.top - 4 - h); const left = Math.min(Math.max(MARGIN, a.right - w), innerWidth - w - MARGIN); setPos({ top, left }); }, [anchor]); // Focus sur la première action une fois le menu placé (masqué, il ne le prend pas). useEffect(() => { if (pos) ref.current?.querySelector("button, a")?.focus({ preventScroll: true }); }, [pos]); useEffect(() => { const inside = (t: EventTarget | null) => t instanceof Node && (ref.current?.contains(t) || anchor.contains(t)); const onDown = (ev: PointerEvent) => { if (!inside(ev.target)) onClose(); }; const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") { // Ni désélection, ni fermeture du panneau flottant : seulement le menu. ev.preventDefault(); ev.stopPropagation(); onClose(); anchor.focus(); } else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { const items = [...(ref.current?.querySelectorAll("button, a") ?? [])]; if (!items.length) return; ev.preventDefault(); const i = items.indexOf(document.activeElement as HTMLElement); const next = ev.key === "ArrowDown" ? (i + 1) % items.length : (i - 1 + items.length) % items.length; items[next].focus(); } }; const onScroll = (ev: Event) => { if (!(ev.target instanceof Node && ref.current?.contains(ev.target))) onClose(); }; document.addEventListener("pointerdown", onDown, true); document.addEventListener("keydown", onKey, true); document.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onClose); return () => { document.removeEventListener("pointerdown", onDown, true); document.removeEventListener("keydown", onKey, true); document.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onClose); }; }, [anchor, onClose]); return createPortal(
{ if ((ev.target as HTMLElement).closest("button, a")) setTimeout(onClose); }} > {children}
, document.body, ); }