feat(ui): add smart dropdown placement to block editor menus

- introduce `useDropdownPlacement` hook to compute above/below positioning based on available viewport space
- apply dynamic `maxHeight` and position class to `BlockInsertMenu` and `BlockActionsMenu` panels
- attach `triggerRef` to menu trigger buttons for accurate anchor rect calculation
- use `useLayoutEffect` to avoid layout flicker on open
This commit is contained in:
2026-04-25 20:51:52 -04:00
parent 2204cefabf
commit c32ab0909c
@@ -1,10 +1,39 @@
'use client';
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
import React, { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, forwardRef } from 'react';
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
// Style « boîte » pour l'icône d'un type de bloc, repris du SlashMenu.
const TYPE_ICON_BOX_CLASS = 'w-8 h-8 flex items-center justify-center rounded-md border border-neutral-200 dark:border-neutral-700 text-xs font-medium text-neutral-700 dark:text-neutral-300 flex-shrink-0';
const DROPDOWN_MAX_HEIGHT = 360;
const DROPDOWN_MIN_HEIGHT = 160;
const DROPDOWN_VIEWPORT_MARGIN = 8;
const DROPDOWN_GAP = 6;
// Décide si un dropdown ancré au `triggerRef` doit s'ouvrir au-dessus ou en
// dessous, et calcule le maxHeight disponible. Recalculé chaque fois que le
// menu s'ouvre (anchor stable tant qu'il est ouvert). Quand il s'ouvre vers
// le haut, le contenu est ancré par `bottom: 100%` côté caller — comme ça
// le bas reste collé au bouton et le contenu rétrécit vers le haut.
function useDropdownPlacement(open, triggerRef) {
const [placement, setPlacement] = useState({ side: 'below', maxHeight: DROPDOWN_MAX_HEIGHT });
useLayoutEffect(() => {
if (!open) return;
const el = triggerRef.current;
if (!el || typeof window === 'undefined') return;
const rect = el.getBoundingClientRect();
const vh = window.innerHeight;
const spaceBelow = vh - rect.bottom - DROPDOWN_VIEWPORT_MARGIN - DROPDOWN_GAP;
const spaceAbove = rect.top - DROPDOWN_VIEWPORT_MARGIN - DROPDOWN_GAP;
if (spaceBelow >= Math.min(DROPDOWN_MAX_HEIGHT, DROPDOWN_MIN_HEIGHT) || spaceBelow >= spaceAbove) {
setPlacement({ side: 'below', maxHeight: Math.max(DROPDOWN_MIN_HEIGHT, Math.min(DROPDOWN_MAX_HEIGHT, spaceBelow)) });
} else {
setPlacement({ side: 'above', maxHeight: Math.max(DROPDOWN_MIN_HEIGHT, Math.min(DROPDOWN_MAX_HEIGHT, spaceAbove)) });
}
}, [open, triggerRef]);
return placement;
}
import { getBlockDef, listBlocks } from './blockRegistry.js';
import { inlineLength } from './inline/types.js';
import { inlineToDom, domToInline } from './inline/serialize.js';
@@ -31,6 +60,8 @@ function BlockInsertMenu({
onInsert,
}) {
const containerRef = useRef(null);
const triggerRef = useRef(null);
const { side, maxHeight } = useDropdownPlacement(open, triggerRef);
useEffect(() => {
if (!open) return;
@@ -62,9 +93,12 @@ function BlockInsertMenu({
};
}
const panelPositionClass = side === 'above' ? 'bottom-full mb-1' : 'top-full mt-1';
return (
<div ref={containerRef} className="relative">
<button
ref={triggerRef}
type="button"
tabIndex={-1}
title="Insérer un bloc"
@@ -76,8 +110,11 @@ function BlockInsertMenu({
</button>
{open && (
<div className="absolute left-0 mt-1 w-64 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50">
<div className="p-1.5 flex flex-col gap-0.5 max-h-80 overflow-y-auto">
<div
className={`absolute left-0 ${panelPositionClass} w-64 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50 flex flex-col`}
style={{ maxHeight }}
>
<div className="p-1.5 flex flex-col gap-0.5 overflow-y-auto">
<div className="px-2 pt-1 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
Insérer un bloc
</div>
@@ -118,6 +155,8 @@ function BlockActionsMenu({
onDelete,
}) {
const containerRef = useRef(null);
const triggerRef = useRef(null);
const { side, maxHeight } = useDropdownPlacement(open, triggerRef);
const [submenuOpen, setSubmenuOpen] = useState(false);
const submenuTimerRef = useRef(null);
@@ -173,9 +212,12 @@ function BlockActionsMenu({
};
}
const panelPositionClass = side === 'above' ? 'bottom-full mb-1' : 'top-full mt-1';
return (
<div ref={containerRef} className="relative">
<button
ref={triggerRef}
type="button"
tabIndex={-1}
title="Glisser pour réordonner ou cliquer pour les actions"
@@ -188,8 +230,11 @@ function BlockActionsMenu({
</button>
{open && (
<div className="absolute left-0 mt-1 w-56 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50">
<div className="p-1.5 flex flex-col gap-0.5">
<div
className={`absolute left-0 ${panelPositionClass} w-56 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50 flex flex-col`}
style={{ maxHeight }}
>
<div className="p-1.5 flex flex-col gap-0.5 overflow-y-auto">
{transformOptions.length > 0 && (
<div
className="relative"