diff --git a/frontend/src/ActionMenu.tsx b/frontend/src/ActionMenu.tsx index 311c8dd..d0f0662 100644 --- a/frontend/src/ActionMenu.tsx +++ b/frontend/src/ActionMenu.tsx @@ -1 +1,92 @@ -PLACEHOLDER \ No newline at end of file +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, + ); +}