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:
gsxdsm
2026-04-04 08:52:05 -07:00
parent 0c31127169
commit 28fc9787d2
4 changed files with 198 additions and 5 deletions

View File

@@ -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>

View File

@@ -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");
});
});
});

View File

@@ -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;