Explorateur : composant ActionMenu (contenu manquant au commit précédent)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AW78w4N95h6PXiCResh2v
This commit is contained in:
1 parent
8e0b5ea0d9
commit
bc9e096afd
1 file changed
+92
-1
@@ -1 +1,92 @@
|
||||
PLACEHOLDER
|
||||
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 <body>
|
||||
* 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<HTMLDivElement>(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<HTMLElement>("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<HTMLElement>("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(
|
||||
<div
|
||||
ref={ref}
|
||||
className="actions menu-popup"
|
||||
role="menu"
|
||||
style={pos ?? { top: 0, left: 0, visibility: "hidden" }}
|
||||
// Une action choisie referme le menu (après coup, pour laisser un lien de
|
||||
// téléchargement aboutir).
|
||||
onClick={(ev) => {
|
||||
if ((ev.target as HTMLElement).closest("button, a")) setTimeout(onClose);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user