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:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user