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);
|
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||||
}, [isOpen]);
|
}, [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
|
// Keyboard navigation
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { loadAllAppCss } from "../../test/cssFixture";
|
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 },
|
{ 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", () => {
|
describe("CustomModelDropdown", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
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 () => {
|
it("collapses provider rows, preserves special rows, and persists the preference", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
@@ -151,10 +239,15 @@ describe("CustomModelDropdown", () => {
|
|||||||
expect(screen.queryByText("Claude Sonnet")).toBeNull();
|
expect(screen.queryByText("Claude Sonnet")).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "Expand anthropic" })).toBeTruthy();
|
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.getByText("Claude Sonnet")).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "Collapse anthropic" })).toHaveAttribute("aria-expanded", "true");
|
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();
|
view.unmount();
|
||||||
window.localStorage.setItem("fusion-dashboard-model-dropdown-collapsed-providers", "not-json");
|
window.localStorage.setItem("fusion-dashboard-model-dropdown-collapsed-providers", "not-json");
|
||||||
render(<CustomModelDropdown label="Other model" value="" onChange={vi.fn()} models={MOCK_MODELS} />);
|
render(<CustomModelDropdown label="Other model" value="" onChange={vi.fn()} models={MOCK_MODELS} />);
|
||||||
|
|||||||
Reference in New Issue
Block a user