feat(FN-879): add portaled model menu with layering and filter-input stability
- Port model menu dropdown to a portal for proper z-index layering above overlays - Stabilize filter input behavior inside the portaled menu - Add CSS styles for portal-based menu positioning and layering - Add comprehensive tests for the portaled QuickEntryBox model menu - Add changeset for the published package fix
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
|
||||
import type { ModelInfo, RefinementType } from "../api";
|
||||
@@ -98,6 +99,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
|
||||
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
|
||||
const modelMenuRef = useRef<HTMLDivElement>(null);
|
||||
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
|
||||
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||
@@ -208,6 +212,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set portal root for model menu rendering
|
||||
useEffect(() => {
|
||||
setPortalRoot(document.body);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -269,7 +278,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
if (!isModelMenuOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as Node;
|
||||
const clickedInsideTrigger = modelMenuRef.current?.contains(target);
|
||||
const clickedInsidePortal = modelMenuPortalRef.current?.contains(target);
|
||||
// Also check for clicks inside CustomModelDropdown's portaled dropdown
|
||||
const clickedInsideCombobox = (target instanceof Element) && (target.closest?.(".model-combobox-dropdown--portal") != null);
|
||||
|
||||
if (!clickedInsideTrigger && !clickedInsidePortal && !clickedInsideCombobox) {
|
||||
setIsModelMenuOpen(false);
|
||||
setActiveModelSubmenu(null);
|
||||
}
|
||||
@@ -437,17 +452,47 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateModelMenuPosition = useCallback(() => {
|
||||
const trigger = modelMenuRef.current?.querySelector(".quick-entry-model-trigger") as HTMLElement | null;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
setModelMenuPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 240),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleModelMenu = useCallback(() => {
|
||||
setIsModelMenuOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
setShowDeps(false);
|
||||
// Compute position synchronously so the portal renders on first paint
|
||||
updateModelMenuPosition();
|
||||
} else {
|
||||
setActiveModelSubmenu(null);
|
||||
setModelMenuPosition(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [updateModelMenuPosition]);
|
||||
|
||||
// Keep model menu portal anchored during scroll/resize
|
||||
useEffect(() => {
|
||||
if (!isModelMenuOpen) return;
|
||||
|
||||
const handleReposition = () => updateModelMenuPosition();
|
||||
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
};
|
||||
}, [isModelMenuOpen, updateModelMenuPosition]);
|
||||
|
||||
const handlePlanningModelChange = useCallback((value: string) => {
|
||||
const next = parseModelSelection(value);
|
||||
@@ -794,8 +839,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: " Models"}
|
||||
</button>
|
||||
{isModelMenuOpen && (
|
||||
<div className="model-nested-menu" onMouseDown={(e) => e.preventDefault()} data-testid="model-nested-menu">
|
||||
{isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
|
||||
<div
|
||||
ref={modelMenuPortalRef}
|
||||
className="model-nested-menu model-nested-menu--portal"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
data-testid="model-nested-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${modelMenuPosition.top}px`,
|
||||
left: `${modelMenuPosition.left}px`,
|
||||
width: `${modelMenuPosition.width}px`,
|
||||
}}
|
||||
>
|
||||
{activeModelSubmenu === null ? (
|
||||
// Top-level menu with Plan/Executor/Validator choices
|
||||
<div className="model-menu-items">
|
||||
@@ -903,7 +959,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
portalRoot,
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1717,4 +1717,127 @@ describe("QuickEntryBox", () => {
|
||||
expect(payload.modelPresetId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-879: portaled model menu layering and filter-input stability", () => {
|
||||
it("renders model menu as a portal in document.body (not inside QuickEntryBox)", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
const menu = screen.getByTestId("model-nested-menu");
|
||||
expect(menu).toBeTruthy();
|
||||
|
||||
// The portaled menu should have the --portal modifier class
|
||||
expect(menu.classList.contains("model-nested-menu--portal")).toBe(true);
|
||||
|
||||
// The menu should be a direct child of document.body, NOT inside the QuickEntryBox container
|
||||
const quickEntryBox = screen.getByTestId("quick-entry-box");
|
||||
expect(quickEntryBox.contains(menu)).toBe(false);
|
||||
expect(document.body.contains(menu)).toBe(true);
|
||||
});
|
||||
|
||||
it("positions the portaled menu with fixed positioning to escape column overflow", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
const menu = screen.getByTestId("model-nested-menu");
|
||||
expect(menu).toBeTruthy();
|
||||
|
||||
// The portaled menu should use fixed positioning
|
||||
expect(menu.style.position).toBe("fixed");
|
||||
// Should have explicit top, left, and width set
|
||||
expect(menu.style.top).toBeTruthy();
|
||||
expect(menu.style.left).toBeTruthy();
|
||||
expect(menu.style.width).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not close model menu when clicking inside CustomModelDropdown portal", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
|
||||
|
||||
// Navigate to executor submenu (shows CustomModelDropdown mock)
|
||||
fireEvent.click(screen.getByTestId("model-menu-executor"));
|
||||
expect(screen.getByTestId("custom-model-dropdown-executor model")).toBeTruthy();
|
||||
|
||||
// Simulate a mousedown inside the CustomModelDropdown's portaled dropdown.
|
||||
// In production, the CustomModelDropdown renders its dropdown as a portal
|
||||
// with class "model-combobox-dropdown--portal". The outside-click handler
|
||||
// must recognize clicks inside this portal as internal interactions.
|
||||
const comboboxPortal = document.createElement("div");
|
||||
comboboxPortal.className = "model-combobox-dropdown--portal";
|
||||
const filterInput = document.createElement("input");
|
||||
filterInput.type = "text";
|
||||
comboboxPortal.appendChild(filterInput);
|
||||
document.body.appendChild(comboboxPortal);
|
||||
|
||||
try {
|
||||
// Dispatch mousedown on the filter input inside the combobox portal
|
||||
fireEvent.mouseDown(filterInput);
|
||||
|
||||
// The model menu should remain open
|
||||
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
|
||||
} finally {
|
||||
document.body.removeChild(comboboxPortal);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not close model menu when clicking inside the model-nested-menu portal itself", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
const menu = screen.getByTestId("model-nested-menu");
|
||||
expect(menu).toBeTruthy();
|
||||
|
||||
// Click inside the menu portal (simulating click on a menu item)
|
||||
fireEvent.mouseDown(menu);
|
||||
|
||||
// Menu should still be open
|
||||
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes model menu on outside click (click outside both trigger and portal)", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with menu" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
|
||||
|
||||
// Click on an element outside both the trigger and the portal
|
||||
const outsideElement = document.createElement("div");
|
||||
document.body.appendChild(outsideElement);
|
||||
try {
|
||||
fireEvent.mouseDown(outsideElement);
|
||||
} finally {
|
||||
document.body.removeChild(outsideElement);
|
||||
}
|
||||
|
||||
// Menu should be closed
|
||||
expect(screen.queryByTestId("model-nested-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("repositions portaled menu on window resize while open", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
const menu = screen.getByTestId("model-nested-menu");
|
||||
const initialTop = menu.style.top;
|
||||
|
||||
// Trigger a resize event
|
||||
fireEvent.resize(window);
|
||||
|
||||
// Menu should still be open and have position styles (may or may not change in test env)
|
||||
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
|
||||
expect(menu.style.position).toBe("fixed");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11550,6 +11550,14 @@ html .column.drag-over * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Portaled variant — rendered via createPortal to escape column overflow clipping */
|
||||
.model-nested-menu--portal {
|
||||
position: fixed;
|
||||
margin-top: 0;
|
||||
z-index: 1000;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.model-menu-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user