FN-9011: fix model dropdown provider toggles

Keep portaled model-provider toggles interactive when hosts use document-level outside-close handlers.

- Stop pointer and mouse events at the portaled dropdown boundary.
- Cover outside-close, multiple-instance, modal, desktop, and mobile toggle interactions.
- Add a patch changeset for the dropdown fix.

Files changed:
 .changeset/fn-9011-model-dropdown-toggle.md        |  7 ++
 .../app/components/CustomModelDropdown.tsx         | 17 ++++
 .../__tests__/CustomModelDropdown.test.tsx         | 95 +++++++++++++++++++++-
 3 files changed, 118 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-9011

Fusion-Task-Lineage: 37559d4e-27df-4ad5-8b3e-4ec28d55e2e6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-12 15:08:09 -07:00
parent 6e04b31579
commit 02f7b589e2
3 changed files with 118 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the collapse/expand toggle in model selection dropdowns.
category: fix
dev: Stop portal-bound pointer and mouse events before document-level outside-close handlers can unmount CustomModelDropdown.

View File

@@ -518,6 +518,23 @@ export function CustomModelDropdown({
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [isOpen]);
/*
FNXC:ModelDropdown 2026-08-12-21:56:
Every dashboard host must treat the document.body model-menu portal as inside its model control. Stop native pointer and mouse events at the portal boundary before document-level outside-close listeners run, so provider collapse/expand remains interactive on desktop and mobile without requiring each consumer to duplicate the portal exemption.
*/
useEffect(() => {
const dropdown = dropdownRef.current;
if (!dropdown) return;
const stopPortalOutsideClose = (event: Event) => event.stopPropagation();
dropdown.addEventListener("pointerdown", stopPortalOutsideClose);
dropdown.addEventListener("mousedown", stopPortalOutsideClose);
return () => {
dropdown.removeEventListener("pointerdown", stopPortalOutsideClose);
dropdown.removeEventListener("mousedown", stopPortalOutsideClose);
};
}, [dropdownPosition, isOpen]);
// Keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {

View File

@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useEffect, useRef, useState } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { loadAllAppCss } from "../../test/cssFixture";
@@ -22,6 +23,26 @@ const COLLAPSIBLE_MODELS = [
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini", reasoning: false, contextWindow: 128000 },
];
/** Mirrors dashboard hosts that mistake the document.body portal for an outside click. */
function UnGuardedOutsideCloseHost() {
const [isMounted, setIsMounted] = useState(true);
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const closeOnOutsideMouseDown = (event: MouseEvent) => {
if (!hostRef.current?.contains(event.target as Node)) setIsMounted(false);
};
document.addEventListener("mousedown", closeOnOutsideMouseDown);
return () => document.removeEventListener("mousedown", closeOnOutsideMouseDown);
}, []);
return isMounted ? (
<div ref={hostRef}>
<CustomModelDropdown label="Model" value="" onChange={vi.fn()} models={COLLAPSIBLE_MODELS} />
</div>
) : null;
}
describe("CustomModelDropdown", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -111,6 +132,73 @@ describe("CustomModelDropdown", () => {
}
});
it.each([
{ breakpoint: "desktop", mobile: false },
{ breakpoint: "mobile", mobile: true },
])("keeps the portaled provider toggle interactive in an unguarded outside-close host at $breakpoint", async ({ mobile }) => {
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: mobile && (query === "(max-width: 768px)" || query === "(max-width: 640px)"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
} as MediaQueryList));
const user = userEvent.setup();
render(<UnGuardedOutsideCloseHost />);
await user.click(screen.getByRole("button", { name: "Model" }));
const toggle = await screen.findByTestId("model-combobox-provider-toggle-anthropic");
await user.click(toggle);
expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument();
expect(screen.getByTestId("model-combobox-provider-toggle-anthropic")).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Claude Sonnet")).toBeNull();
await user.click(screen.getByTestId("model-combobox-provider-toggle-anthropic"));
expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument();
expect(screen.getByTestId("model-combobox-provider-toggle-anthropic")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Claude Sonnet")).toBeTruthy();
});
it("keeps independently mounted portaled dropdown instances interactive", async () => {
const user = userEvent.setup();
render(
<>
<CustomModelDropdown label="First model" value="" onChange={vi.fn()} models={COLLAPSIBLE_MODELS} />
<CustomModelDropdown label="Second model" value="" onChange={vi.fn()} models={COLLAPSIBLE_MODELS} />
</>,
);
await user.click(screen.getByRole("button", { name: "First model" }));
const firstPortal = await screen.findByTestId("model-combobox-portal");
await user.click(within(firstPortal).getByTestId("model-combobox-provider-toggle-anthropic"));
expect(within(firstPortal).queryByText("Claude Sonnet")).toBeNull();
await user.click(screen.getByRole("button", { name: "Second model" }));
const secondPortal = await screen.findByTestId("model-combobox-portal");
await user.click(within(secondPortal).getByTestId("model-combobox-provider-toggle-anthropic"));
expect(within(secondPortal).queryByText("Claude Sonnet")).toBeNull();
});
it("keeps the provider toggle interactive in a modal-style host", async () => {
const user = userEvent.setup();
render(
<div role="dialog" aria-label="Model settings">
<CustomModelDropdown label="Modal model" value="" onChange={vi.fn()} models={COLLAPSIBLE_MODELS} />
</div>,
);
await user.click(screen.getByRole("button", { name: "Modal model" }));
await user.click(screen.getByTestId("model-combobox-provider-toggle-anthropic"));
expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet")).toBeNull();
});
it("collapses provider rows, preserves special rows, and persists the preference", async () => {
const user = userEvent.setup();
@@ -151,10 +239,15 @@ describe("CustomModelDropdown", () => {
expect(screen.queryByText("Claude Sonnet")).toBeNull();
expect(screen.getByRole("button", { name: "Expand anthropic" })).toBeTruthy();
await user.type(screen.getByPlaceholderText("Filter models…"), "sonnet");
const filter = screen.getByPlaceholderText("Filter models…");
await user.type(filter, "sonnet");
expect(screen.getByText("Claude Sonnet")).toBeTruthy();
expect(screen.getByRole("button", { name: "Collapse anthropic" })).toHaveAttribute("aria-expanded", "true");
await user.clear(filter);
expect(screen.queryByText("Claude Sonnet")).toBeNull();
expect(screen.getByRole("button", { name: "Expand anthropic" })).toHaveAttribute("aria-expanded", "false");
view.unmount();
window.localStorage.setItem("fusion-dashboard-model-dropdown-collapsed-providers", "not-json");
render(<CustomModelDropdown label="Other model" value="" onChange={vi.fn()} models={MOCK_MODELS} />);