From bc9e096afd20eed4f55484f38a933c6a69b037bd Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 14 Oct 2026 13:40:32 +0200 Subject: [PATCH] =?UTF-8?q?Explorateur=20:=20composant=20ActionMenu=20(con?= =?UTF-8?q?tenu=20manquant=20au=20commit=20pr=C3=A9c=C3=A9dent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017AW78w4N95h6PXiCResh2v --- frontend/src/ActionMenu.tsx | 93 ++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) 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, + ); +}