From c0e7b5158a0a13f6b87a6fc477cc46f4dd8100d4 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:53:22 +0530 Subject: [PATCH] =?UTF-8?q?fix(flows):=20flip=20the=20workflow=20row=20?= =?UTF-8?q?=E2=8B=AE=20menu=20upward=20when=20it=20would=20clip=20at=20the?= =?UTF-8?q?=20bottom=20(#4670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/components/flows/FlowRowMenu.tsx | 104 +++++++++++++++++------ 1 file changed, 78 insertions(+), 26 deletions(-) diff --git a/app/src/components/flows/FlowRowMenu.tsx b/app/src/components/flows/FlowRowMenu.tsx index c18f84594..3a38a851e 100644 --- a/app/src/components/flows/FlowRowMenu.tsx +++ b/app/src/components/flows/FlowRowMenu.tsx @@ -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(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const buttonRef = useRef(null); + const menuRef = useRef(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 ( -
+ <> - {open && ( -
- {items.map(item => ( - - ))} -
- )} -
+ {open && + createPortal( +
+ {items.map(item => ( + + ))} +
, + document.body + )} + ); }