fix(flows): flip the workflow row ⋮ menu upward when it would clip at the bottom (#4670)

This commit is contained in:
Cyrus Gray
2026-07-08 01:53:22 +05:30
committed by GitHub
parent 8018eeb6ef
commit c0e7b5158a
+78 -26
View File
@@ -4,10 +4,16 @@
* actions (View runs, Run) stay uncluttered, and keeps the destructive Delete
* out of the flat button row. Closes on Escape, outside click, or item select.
*
* The menu is rendered in a portal with fixed positioning so it escapes the
* list card's `overflow-hidden` clipping — otherwise the last row's menu would
* be cut off by the card edge. It flips above the button when there isn't room
* below in the viewport.
*
* Presentational + local open state only — each item calls back up to
* `FlowListRow`, which routes to `FlowsPage`'s handlers.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useT } from '../../lib/i18n/I18nContext';
@@ -37,18 +43,54 @@ function KebabIcon() {
);
}
const MENU_WIDTH = 160; // matches min-w-[10rem]
export default function FlowRowMenu({ items, rowId }: FlowRowMenuProps) {
const { t } = useT();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(() => setOpen(false), open);
// Close on any click outside the menu container.
// Position the portaled menu in viewport coords so it escapes the list card's
// `overflow-hidden`. Flip above the button when there isn't room below.
const place = useCallback(() => {
const btn = buttonRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const menuH = menuRef.current?.offsetHeight ?? 8 + items.length * 30;
const openAbove = rect.bottom + menuH + 8 > window.innerHeight && rect.top - menuH - 4 > 0;
setPos({
top: openAbove ? rect.top - menuH - 4 : rect.bottom + 4,
left: Math.max(8, rect.right - MENU_WIDTH),
});
}, [items.length]);
useLayoutEffect(() => {
if (open) place();
}, [open, place]);
// Reposition on scroll/resize while open.
useEffect(() => {
if (!open) return;
const reposition = () => place();
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, true);
return () => {
window.removeEventListener('resize', reposition);
window.removeEventListener('scroll', reposition, true);
};
}, [open, place]);
// Close on any click outside the button and the (portaled) menu.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node | null)) setOpen(false);
const target = event.target as Node | null;
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
@@ -60,8 +102,9 @@ export default function FlowRowMenu({ items, rowId }: FlowRowMenuProps) {
}, []);
return (
<div className="relative" ref={containerRef}>
<>
<button
ref={buttonRef}
type="button"
data-testid={`flow-menu-${rowId}`}
aria-haspopup="menu"
@@ -73,26 +116,35 @@ export default function FlowRowMenu({ items, rowId }: FlowRowMenuProps) {
<KebabIcon />
</button>
{open && (
<div
role="menu"
data-testid={`flow-menu-list-${rowId}`}
className="absolute right-0 z-20 mt-1 min-w-[10rem] overflow-hidden rounded-xl border border-line bg-surface py-1 shadow-lg">
{items.map(item => (
<button
key={item.key}
type="button"
role="menuitem"
data-testid={item.testId}
onClick={() => select(item.onSelect)}
className={`block w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-surface-hover ${
item.danger ? 'text-coral-600 dark:text-coral-400' : 'text-content-secondary'
}`}>
{item.label}
</button>
))}
</div>
)}
</div>
{open &&
createPortal(
<div
ref={menuRef}
role="menu"
data-testid={`flow-menu-list-${rowId}`}
style={{
position: 'fixed',
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
minWidth: '10rem',
}}
className="z-50 overflow-hidden rounded-xl border border-line bg-surface py-1 shadow-lg">
{items.map(item => (
<button
key={item.key}
type="button"
role="menuitem"
data-testid={item.testId}
onClick={() => select(item.onSelect)}
className={`block w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-surface-hover ${
item.danger ? 'text-coral-600 dark:text-coral-400' : 'text-content-secondary'
}`}>
{item.label}
</button>
))}
</div>,
document.body
)}
</>
);
}