fix(FN-910): portal refine dropdown to escape column overflow

- Port the refine/AI dropdown in QuickEntryBox to use a portal so it renders outside the column container
- Add CSS styles for the portal dropdown positioning and layout
- Prevents dropdown from being clipped by overflow:hidden on board columns
This commit is contained in:
gsxdsm
2026-04-04 19:38:36 -07:00
parent 1ae3fc4a6a
commit 1348e78bc1
2 changed files with 70 additions and 5 deletions

View File

@@ -114,6 +114,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false); const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
const [isRefining, setIsRefining] = useState(false); const [isRefining, setIsRefining] = useState(false);
const refineMenuRef = useRef<HTMLDivElement>(null); const refineMenuRef = useRef<HTMLDivElement>(null);
const refineMenuPortalRef = useRef<HTMLDivElement>(null);
const [refineMenuPosition, setRefineMenuPosition] = useState<{ top: number; left: number } | null>(null);
// Use parent-provided favorites when available, otherwise internal state // Use parent-provided favorites when available, otherwise internal state
const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders; const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders;
@@ -264,7 +266,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
if (!isRefineMenuOpen) return; if (!isRefineMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) { const target = e.target as Node;
const clickedInsideTrigger = refineMenuRef.current?.contains(target);
const clickedInsidePortal = refineMenuPortalRef.current?.contains(target);
if (!clickedInsideTrigger && !clickedInsidePortal) {
setIsRefineMenuOpen(false); setIsRefineMenuOpen(false);
} }
}; };
@@ -493,6 +499,24 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}); });
}, [updateModelMenuPosition]); }, [updateModelMenuPosition]);
const updateRefineMenuPosition = useCallback(() => {
const trigger = refineMenuRef.current?.querySelector(".refine-button") as HTMLElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth <= 640;
// Ensure the menu doesn't overflow off the right edge on small screens
const menuWidth = Math.min(200, viewportWidth - 16);
const left = Math.min(rect.left, viewportWidth - menuWidth - 8);
setRefineMenuPosition({
top: rect.bottom + 4,
left,
});
}, []);
// Keep model menu portal anchored during scroll/resize // Keep model menu portal anchored during scroll/resize
useEffect(() => { useEffect(() => {
if (!isModelMenuOpen) return; if (!isModelMenuOpen) return;
@@ -508,6 +532,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}; };
}, [isModelMenuOpen, updateModelMenuPosition]); }, [isModelMenuOpen, updateModelMenuPosition]);
// Keep refine menu portal anchored during scroll/resize
useEffect(() => {
if (!isRefineMenuOpen) return;
const handleReposition = () => updateRefineMenuPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isRefineMenuOpen, updateRefineMenuPosition]);
const handlePlanningModelChange = useCallback((value: string) => { const handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value); const next = parseModelSelection(value);
setPlanningProvider(next.provider); setPlanningProvider(next.provider);
@@ -724,7 +763,18 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<button <button
type="button" type="button"
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`} className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
onClick={() => setIsRefineMenuOpen((prev) => !prev)} onClick={() => {
setIsRefineMenuOpen((prev) => {
const next = !prev;
if (next) {
// Compute position synchronously so the portal renders on first paint
updateRefineMenuPosition();
} else {
setRefineMenuPosition(null);
}
return next;
});
}}
disabled={!description.trim() || isRefining} disabled={!description.trim() || isRefining}
data-testid="refine-button" data-testid="refine-button"
title="Refine description with AI" title="Refine description with AI"
@@ -732,10 +782,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<Sparkles size={12} style={{ verticalAlign: "middle" }} /> <Sparkles size={12} style={{ verticalAlign: "middle" }} />
{isRefining ? "Refining..." : "Refine"} {isRefining ? "Refining..." : "Refine"}
</button> </button>
{isRefineMenuOpen && ( {isRefineMenuOpen && portalRoot && refineMenuPosition && createPortal(
<div <div
className="refine-menu" ref={refineMenuPortalRef}
className="refine-menu refine-menu--portal"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
style={{
position: "fixed",
top: `${refineMenuPosition.top}px`,
left: `${refineMenuPosition.left}px`,
}}
> >
<div <div
className="refine-menu-item" className="refine-menu-item"
@@ -769,7 +825,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<div className="refine-menu-item-title">Simplify</div> <div className="refine-menu-item-title">Simplify</div>
<div className="refine-menu-item-desc">Simplify and make more concise</div> <div className="refine-menu-item-desc">Simplify and make more concise</div>
</div> </div>
</div> </div>,
portalRoot,
)} )}
</div> </div>
</div> </div>

View File

@@ -4846,6 +4846,14 @@ body {
overflow: hidden; overflow: hidden;
} }
.refine-menu--portal {
position: fixed;
z-index: 1000;
min-width: 200px;
max-width: calc(100vw - 16px);
margin-top: 0;
}
.refine-button { .refine-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;