(null);
+ const [panel, setPanelElement] = useState(null);
+ const [topLayer, setTopLayer] = useState(false);
+ const [layout, setLayout] = useState(null);
+ const [positioned, setPositioned] = useState(false);
+
+ const setAnchor = useCallback((node: A | null) => setAnchorElement(node), []);
+ // Deux états, pas un objet : une ref de rappel recréée rattache le même nœud,
+ // et un objet neuf relancerait la mesure à chaque rendu.
+ const setPanel = useCallback((node: P | null) => {
+ setPanelElement(node);
+ if (node) setTopLayer(isTopLayerPanel(node));
+ }, []);
+
+ // --- Ancre virtuelle -------------------------------------------------
+ // Stable : son rectangle se relit à chaque mesure, donc déplacer le point ne
+ // rebranche ni écouteurs ni observateurs.
const { x: anchorX, y: anchorY } = anchorPoint ?? {};
- const virtualAnchor = useMemo(
- () =>
- anchorX === undefined || anchorY === undefined
- ? null
- : { getBoundingClientRect: () => new DOMRect(anchorX, anchorY, 0, 0) },
- [anchorX, anchorY],
+ const hasPoint = anchorX !== undefined && anchorY !== undefined;
+ const pointRef = useRef({ x: 0, y: 0 });
+ const virtualAnchor = useMemo(
+ () => ({
+ getBoundingClientRect: () => new DOMRect(pointRef.current.x, pointRef.current.y, 0, 0),
+ }),
+ [],
);
+ const anchor = hasPoint ? virtualAnchor : anchorElement;
- const middleware = [
- offset(typeof gap === 'number' ? gap : { mainAxis: gap.main ?? 0, crossAxis: gap.cross ?? 0 }),
- ...(allowFlip ? [flip({ padding: 8 })] : []),
- shift({ padding: 8 }),
- ...(matchWidth
- ? [
- size({
- apply({ rects, elements }) {
- elements.floating.style.minWidth = `${rects.reference.width}px`;
- },
- }),
- ]
- : []),
- ];
-
- const floating = useFloating({
- placement,
- middleware,
- ...(virtualAnchor ? { elements: { reference: virtualAnchor } } : null),
- // Transmis à Floating UI, et pas seulement lu ici : c'est ce qui remet
- // `isPositioned` à faux à la fermeture, donc ce qui fait qu'une réouverture
- // attend de nouveau sa position au lieu de peindre l'ancienne.
- open,
- // `autoUpdate` accroche le défilement, le redimensionnement et le
- // redimensionnement de l'ancre : sans lui le panneau reste où il a été posé.
- // Il n'est branché que pendant l'ouverture, sinon il observe pour rien.
- whileElementsMounted: open ? autoUpdate : undefined,
- });
+ /** Mesure de la session en cours, pour qu'un point déplacé la relance. */
+ const updateRef = useRef<(() => void) | null>(null);
+ useEffect(() => {
+ if (anchorX === undefined || anchorY === undefined) return;
+ pointRef.current = { x: anchorX, y: anchorY };
+ updateRef.current?.();
+ }, [anchorX, anchorY]);
+
+ const mainGap = typeof gap === 'number' ? gap : (gap.main ?? 0);
+ const crossGap = typeof gap === 'number' ? 0 : (gap.cross ?? 0);
+
+ // --- Une session de positionnement par ouverture ----------------------
+ useEffect(() => {
+ if (!open || !anchor || !panel) return;
+
+ let active = true;
+ let ticket = 0;
+ let last: Layout | null = null;
+
+ const middleware: Middleware[] = [
+ offset({ mainAxis: mainGap, crossAxis: crossGap }),
+ ...(allowFlip ? [flip({ padding: 8 })] : []),
+ shift({ padding: 8 }),
+ ...(matchWidth
+ ? [
+ size({
+ apply({ rects, elements }) {
+ elements.floating.style.minWidth = `${rects.reference.width}px`;
+ },
+ }),
+ ]
+ : []),
+ growFromAnchor,
+ ];
+
+ const measure = async (current: number) => {
+ // Micro-tâche d'abord : l'effet du composant qui ouvre le panneau court après
+ // ce crochet. Plus tard encore, `isMeasurable` refuse et le redimensionnement relance.
+ await Promise.resolve();
+ if (!active || current !== ticket || !isMeasurable(panel)) return;
+
+ const data = await computePosition(anchor, panel, {
+ placement,
+ strategy: 'absolute',
+ middleware,
+ });
+ // Dépassée par une mesure plus récente, ou arrivée après la fermeture : ignorée.
+ if (!active || current !== ticket) return;
+
+ const next = toLayout(data, panel);
+ // Défiler ne change pas des coordonnées de document : aucun rendu si rien n'a bougé.
+ if (last && sameLayout(last, next)) return;
+ const first = last === null;
+ last = next;
+ // Synchrone, pour que la position arrive avant la prochaine image.
+ flushSync(() => {
+ setLayout(next);
+ setPositioned(true);
+ });
+ // Le premier placement fait passer le panneau de `fixed` à `absolute`, donc
+ // change son parent de positionnement : une mesure de plus le confirme.
+ if (first) update();
+ };
+
+ const update = () => void measure(++ticket);
+ updateRef.current = update;
+ const stop = autoUpdate(anchor, panel, update);
+ // Filet : l'entrée dans le calque sans changement de taille (réouverture
+ // pendant la sortie) ne réveille pas l'observateur de redimensionnement.
+ panel.addEventListener('toggle', update);
+
+ return () => {
+ active = false;
+ stop();
+ panel.removeEventListener('toggle', update);
+ if (updateRef.current === update) updateRef.current = null;
+ };
+ }, [open, anchor, panel, placement, mainGap, crossGap, allowFlip, matchWidth]);
+
+ // Remis à faux à la fermeture, pas à chaque changement d'ancre : un menu
+ // contextuel déplacé ouvert se replace sans repasser par l'état fermé.
+ useEffect(() => {
+ if (!open) return;
+ return () => setPositioned(false);
+ }, [open]);
+
+ let panelStyle: CSSProperties;
+ if (layout) {
+ // Fermé ou en attente d'une réouverture, le panneau reste à sa dernière position.
+ panelStyle = {
+ position: 'absolute',
+ top: layout.y,
+ left: layout.x,
+ transformOrigin: layout.origin,
+ };
+ } else if (open && topLayer) {
+ // Calque supérieur, première ouverture : en `fixed`, le focus déjà posé par
+ // `showModal()` ne fait pas défiler la page. Ailleurs, `fixed` fausserait la mesure.
+ panelStyle = { position: 'fixed', top: 0, left: 0 };
+ } else {
+ panelStyle = { position: 'absolute', top: 0, left: 0 };
+ }
return {
- setAnchor: floating.refs.setReference as (node: A | null) => void,
- setPanel: floating.refs.setFloating as (node: P | null) => void,
- panelStyle: {
- position: floating.strategy,
- top: 0,
- left: 0,
- transform: `translate(${Math.round(floating.x)}px, ${Math.round(floating.y)}px)`,
- },
- placement: floating.placement,
- isPositioned: floating.isPositioned,
+ setAnchor,
+ setPanel,
+ panelStyle,
+ placement: layout?.placement ?? placement,
+ isPositioned: open && positioned,
};
}
diff --git a/packages/ui-kit-react/src/core/overlay/use-ui-scroll-lock.ts b/packages/ui-kit-react/src/core/overlay/use-ui-scroll-lock.ts
index edf1686..43b3ede 100644
--- a/packages/ui-kit-react/src/core/overlay/use-ui-scroll-lock.ts
+++ b/packages/ui-kit-react/src/core/overlay/use-ui-scroll-lock.ts
@@ -8,18 +8,10 @@ let restore: (() => void) | null = null;
/**
* Bloque le défilement de l'arrière-plan pendant qu'une couche modale est
- * ouverte.
+ * ouverte : `showModal()` rend l'arrière-plan inerte, mais pas immobile.
*
- * C'est le seul manque **mesuré** de `