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:
7
.changeset/fn-9011-model-dropdown-toggle.md
Normal file
7
.changeset/fn-9011-model-dropdown-toggle.md
Normal 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.
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
Reference in New Issue
Block a user