feat(model-selector): support popup menu lifecycle
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { Model } from "../workspace/types";
|
import type { Model } from "../workspace/types";
|
||||||
@@ -76,6 +76,41 @@ describe("ModelSelector", () => {
|
|||||||
await waitFor(() => expect(trigger).toHaveFocus());
|
await waitFor(() => expect(trigger).toHaveFocus());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("notifies menu open and every close path", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { unmount } = render(
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
onSelect={vi.fn().mockResolvedValue(true)}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByRole("button", { name: /Current model/i });
|
||||||
|
|
||||||
|
await user.click(trigger);
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.pointerDown(document.body);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await user.click(trigger);
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await user.click(trigger);
|
||||||
|
await user.click(screen.getByRole("option", { name: /Claude Sonnet/ }));
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(3);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
await user.click(trigger);
|
||||||
|
unmount();
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(4);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
it("supports labeled downward placement for toolbar and Settings reuse", () => {
|
it("supports labeled downward placement for toolbar and Settings reuse", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export function ModelSelector({
|
|||||||
label = "Model",
|
label = "Model",
|
||||||
placement = "up",
|
placement = "up",
|
||||||
className = "",
|
className = "",
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
models: Model[];
|
models: Model[];
|
||||||
current?: AgentState["model"];
|
current?: AgentState["model"];
|
||||||
@@ -101,13 +103,20 @@ export function ModelSelector({
|
|||||||
label?: string;
|
label?: string;
|
||||||
placement?: "up" | "down";
|
placement?: "up" | "down";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selecting, setSelecting] = useState(false);
|
const [selecting, setSelecting] = useState(false);
|
||||||
|
const openRef = useRef(false);
|
||||||
|
const onOpenRef = useRef(onOpen);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
onOpenRef.current = onOpen;
|
||||||
|
onCloseRef.current = onClose;
|
||||||
const labelId = useId();
|
const labelId = useId();
|
||||||
const valueId = useId();
|
const valueId = useId();
|
||||||
const menuId = useId();
|
const menuId = useId();
|
||||||
@@ -133,6 +142,29 @@ export function ModelSelector({
|
|||||||
? `${current.provider} · ${selectedName ?? "Current model"}`
|
? `${current.provider} · ${selectedName ?? "Current model"}`
|
||||||
: "Current model";
|
: "Current model";
|
||||||
const busy = pending || selecting;
|
const busy = pending || selecting;
|
||||||
|
const openMenu = () => {
|
||||||
|
if (openRef.current) return;
|
||||||
|
openRef.current = true;
|
||||||
|
setQuery("");
|
||||||
|
setOpen(true);
|
||||||
|
onOpenRef.current?.();
|
||||||
|
};
|
||||||
|
const closeMenu = (restoreFocus = false) => {
|
||||||
|
if (!openRef.current) return;
|
||||||
|
openRef.current = false;
|
||||||
|
setOpen(false);
|
||||||
|
onCloseRef.current?.();
|
||||||
|
if (restoreFocus)
|
||||||
|
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!openRef.current) return;
|
||||||
|
openRef.current = false;
|
||||||
|
onCloseRef.current?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -142,11 +174,10 @@ export function ModelSelector({
|
|||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setOpen(false);
|
closeMenu(true);
|
||||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
|
||||||
};
|
};
|
||||||
const closeOutside = (event: PointerEvent) => {
|
const closeOutside = (event: PointerEvent) => {
|
||||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
if (!rootRef.current?.contains(event.target as Node)) closeMenu();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", closeOnEscape);
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
window.addEventListener("pointerdown", closeOutside);
|
window.addEventListener("pointerdown", closeOutside);
|
||||||
@@ -162,8 +193,7 @@ export function ModelSelector({
|
|||||||
setSelecting(true);
|
setSelecting(true);
|
||||||
try {
|
try {
|
||||||
if (!(await onSelect(model))) return;
|
if (!(await onSelect(model))) return;
|
||||||
setOpen(false);
|
closeMenu(true);
|
||||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
|
||||||
} finally {
|
} finally {
|
||||||
setSelecting(false);
|
setSelecting(false);
|
||||||
}
|
}
|
||||||
@@ -186,8 +216,8 @@ export function ModelSelector({
|
|||||||
aria-labelledby={`${labelId} ${valueId}`}
|
aria-labelledby={`${labelId} ${valueId}`}
|
||||||
disabled={availableModels.length === 0 || busy || disabled}
|
disabled={availableModels.length === 0 || busy || disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuery("");
|
if (openRef.current) closeMenu();
|
||||||
setOpen((value) => !value);
|
else openMenu();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span id={valueId}>{selectedLabel}</span>
|
<span id={valueId}>{selectedLabel}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user