fix(FN-667): fix model dropdown layering in quick entry
- Render the custom model dropdown menu in a portal so it escapes clipped stacking contexts - Update dashboard styles to give the floating model menu reliable overlay positioning above quick entry surfaces - Refresh dropdown-focused tests to cover portal layering behavior in quick entry and model selector flows - Document the layering behavior in the dashboard README for future UI maintenance
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -13,6 +14,12 @@ export interface CustomModelDropdownProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DropdownPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CustomModelDropdown - A dropdown component combining selection with icon-enhanced provider groups.
|
||||
*
|
||||
@@ -21,6 +28,10 @@ export interface CustomModelDropdownProps {
|
||||
* - Open: Dropdown with search input at top, scrollable list of models grouped by provider with icons
|
||||
* - Filtering: Real-time filtering using filterModels() utility
|
||||
* - Keyboard: Arrow keys navigate, Enter selects, Escape closes, Tab moves focus
|
||||
*
|
||||
* The dropdown listbox is rendered in a portal so it can escape clipping/stacking
|
||||
* contexts created by scrollable modal or board containers while still anchoring to
|
||||
* the trigger button.
|
||||
*/
|
||||
export function CustomModelDropdown({
|
||||
models,
|
||||
@@ -34,8 +45,12 @@ export function CustomModelDropdown({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localFilter, setLocalFilter] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -94,10 +109,25 @@ export function CustomModelDropdown({
|
||||
return optionsList.findIndex((opt) => opt.value === value);
|
||||
}, [optionsList, value]);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPortalRoot(document.body);
|
||||
}, []);
|
||||
|
||||
// Reset highlighted index when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Start with current value highlighted, or first selectable option
|
||||
const selectableIndex = optionsList.findIndex(
|
||||
(opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
|
||||
);
|
||||
@@ -105,26 +135,48 @@ export function CustomModelDropdown({
|
||||
}
|
||||
}, [isOpen, optionsList, currentValueIndex]);
|
||||
|
||||
// Focus search input when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Click outside to close
|
||||
// Focus search input and position dropdown when opening
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
updateDropdownPosition();
|
||||
const rafId = requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
// Keep portaled dropdown anchored during viewport and container scrolling.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleReposition = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener("resize", handleReposition);
|
||||
window.addEventListener("scroll", handleReposition, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleReposition);
|
||||
window.removeEventListener("scroll", handleReposition, true);
|
||||
};
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
// Click outside to close, treating both trigger container and portaled menu as inside.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePointerDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const clickedInsideTrigger = containerRef.current?.contains(target);
|
||||
const clickedInsideDropdown = dropdownRef.current?.contains(target);
|
||||
|
||||
if (!clickedInsideTrigger && !clickedInsideDropdown) {
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Keyboard navigation
|
||||
@@ -136,7 +188,6 @@ export function CustomModelDropdown({
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
// Find next selectable option (skip provider headers)
|
||||
let nextIndex = highlightedIndex;
|
||||
for (let i = 1; i <= optionsList.length; i++) {
|
||||
const idx = (highlightedIndex + i) % optionsList.length;
|
||||
@@ -152,7 +203,6 @@ export function CustomModelDropdown({
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
// Find previous selectable option (skip provider headers)
|
||||
let prevIndex = highlightedIndex;
|
||||
for (let i = 1; i <= optionsList.length; i++) {
|
||||
const idx = (highlightedIndex - i + optionsList.length) % optionsList.length;
|
||||
@@ -186,7 +236,6 @@ export function CustomModelDropdown({
|
||||
break;
|
||||
|
||||
case "Tab":
|
||||
// Close dropdown on tab (focus moves to next field)
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
@@ -229,115 +278,121 @@ export function CustomModelDropdown({
|
||||
|
||||
const hasFilter = localFilter.length > 0;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
className="model-combobox-trigger"
|
||||
onClick={handleTriggerClick}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label}
|
||||
>
|
||||
{currentProvider && (
|
||||
<span className="model-combobox-trigger-icon">
|
||||
<ProviderIcon provider={currentProvider} size="sm" />
|
||||
</span>
|
||||
const dropdownContent = isOpen && dropdownPosition ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="model-combobox-dropdown model-combobox-dropdown--portal"
|
||||
role="listbox"
|
||||
data-testid="model-combobox-portal"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
}}
|
||||
>
|
||||
<div className="model-combobox-search-wrapper">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="model-combobox-search"
|
||||
placeholder="Filter models…"
|
||||
value={localFilter}
|
||||
onChange={(e) => setLocalFilter(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{hasFilter && (
|
||||
<button
|
||||
type="button"
|
||||
className="model-combobox-clear"
|
||||
onClick={handleClearFilter}
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<span className="model-combobox-trigger-text">{selectedDisplayText}</span>
|
||||
<span className="model-combobox-trigger-arrow">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Panel */}
|
||||
{isOpen && (
|
||||
<div className="model-combobox-dropdown" role="listbox">
|
||||
{/* Search Input */}
|
||||
<div className="model-combobox-search-wrapper">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="model-combobox-search"
|
||||
placeholder="Filter models…"
|
||||
value={localFilter}
|
||||
onChange={(e) => setLocalFilter(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{hasFilter && (
|
||||
<button
|
||||
type="button"
|
||||
className="model-combobox-clear"
|
||||
onClick={handleClearFilter}
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="model-combobox-results-count">
|
||||
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="model-combobox-results-count">
|
||||
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
|
||||
{/* Options List */}
|
||||
<div ref={listRef} className="model-combobox-list">
|
||||
{/* Use default option */}
|
||||
<div
|
||||
data-index={0}
|
||||
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
|
||||
onClick={() => handleSelect("")}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
role="option"
|
||||
aria-selected={value === ""}
|
||||
>
|
||||
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
|
||||
</div>
|
||||
|
||||
{/* Provider groups */}
|
||||
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
|
||||
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
|
||||
|
||||
return (
|
||||
<div key={provider} className="model-combobox-group">
|
||||
<div className="model-combobox-optgroup" data-index={groupStartIndex}>
|
||||
<ProviderIcon provider={provider} size="sm" />
|
||||
<span className="model-combobox-optgroup-text">{provider}</span>
|
||||
</div>
|
||||
{providerModels.map((m) => {
|
||||
const optionValue = `${m.provider}/${m.id}`;
|
||||
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
|
||||
const isHighlighted = highlightedIndex === optionIndex;
|
||||
const isSelected = value === optionValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={optionValue}
|
||||
data-index={optionIndex}
|
||||
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
|
||||
onClick={() => handleSelect(optionValue)}
|
||||
onMouseEnter={() => setHighlightedIndex(optionIndex)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<span className="model-combobox-option-text">{m.name}</span>
|
||||
<span className="model-combobox-option-id">{m.id}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* No results message */}
|
||||
{filteredModels.length === 0 && hasFilter && (
|
||||
<div className="model-combobox-no-results">No models match '{localFilter}'</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={listRef} className="model-combobox-list">
|
||||
<div
|
||||
data-index={0}
|
||||
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
|
||||
onClick={() => handleSelect("")}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
role="option"
|
||||
aria-selected={value === ""}
|
||||
>
|
||||
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
|
||||
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
|
||||
|
||||
return (
|
||||
<div key={provider} className="model-combobox-group">
|
||||
<div className="model-combobox-optgroup" data-index={groupStartIndex}>
|
||||
<ProviderIcon provider={provider} size="sm" />
|
||||
<span className="model-combobox-optgroup-text">{provider}</span>
|
||||
</div>
|
||||
{providerModels.map((m) => {
|
||||
const optionValue = `${m.provider}/${m.id}`;
|
||||
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
|
||||
const isHighlighted = highlightedIndex === optionIndex;
|
||||
const isSelected = value === optionValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={optionValue}
|
||||
data-index={optionIndex}
|
||||
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
|
||||
onClick={() => handleSelect(optionValue)}
|
||||
onMouseEnter={() => setHighlightedIndex(optionIndex)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<span className="model-combobox-option-text">{m.name}</span>
|
||||
<span className="model-combobox-option-id">{m.id}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredModels.length === 0 && hasFilter && (
|
||||
<div className="model-combobox-no-results">No models match '{localFilter}'</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
id={id}
|
||||
className="model-combobox-trigger"
|
||||
onClick={handleTriggerClick}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label}
|
||||
>
|
||||
{currentProvider && (
|
||||
<span className="model-combobox-trigger-icon">
|
||||
<ProviderIcon provider={currentProvider} size="sm" />
|
||||
</span>
|
||||
)}
|
||||
<span className="model-combobox-trigger-text">{selectedDisplayText || placeholder}</span>
|
||||
<span className="model-combobox-trigger-arrow">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
{portalRoot && dropdownContent ? createPortal(dropdownContent, portalRoot) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,309 +2,98 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CustomModelDropdown } from "../CustomModelDropdown";
|
||||
import type { ModelInfo } from "../../api";
|
||||
|
||||
// Mock ProviderIcon to avoid rendering actual icons in most tests
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
|
||||
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
|
||||
}));
|
||||
|
||||
const MOCK_MODELS: ModelInfo[] = [
|
||||
const MOCK_MODELS = [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "anthropic", id: "claude-opus-4", name: "Claude Opus 4", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
{ provider: "ollama", id: "llama3", name: "Llama 3", reasoning: false, contextWindow: 4096 },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
models: MOCK_MODELS,
|
||||
value: "",
|
||||
onChange: vi.fn(),
|
||||
label: "Test Model",
|
||||
id: "test-model",
|
||||
};
|
||||
|
||||
describe("CustomModelDropdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders trigger button with placeholder text", () => {
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
expect(screen.getByLabelText("Test Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Use default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders trigger button with selected model name", () => {
|
||||
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" />);
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows provider icon in trigger when model is selected", () => {
|
||||
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" />);
|
||||
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show provider icon in trigger when using default", () => {
|
||||
render(<CustomModelDropdown {...defaultProps} value="" />);
|
||||
expect(screen.queryByTestId(/provider-icon-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens dropdown when trigger is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
expect(screen.getByText("4 models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups models by provider in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
// Provider groups should be visible with icons
|
||||
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-icon-openai")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-icon-ollama")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays provider names in group headers", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
expect(screen.getByText("anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai")).toBeInTheDocument();
|
||||
expect(screen.getByText("ollama")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays model names in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
|
||||
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
|
||||
expect(screen.getByText("Llama 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays model IDs next to names", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onChange when a model is selected", async () => {
|
||||
it("renders the open dropdown in a portal attached to document.body", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<CustomModelDropdown {...defaultProps} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
render(
|
||||
<div data-testid="host-surface">
|
||||
<CustomModelDropdown
|
||||
label="Executor Model"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
models={MOCK_MODELS}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("openai/gpt-4o");
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
expect(portal).toBeInTheDocument();
|
||||
expect(portal).toHaveClass("model-combobox-dropdown--portal");
|
||||
expect(document.body).toContainElement(portal);
|
||||
|
||||
const hostSurface = screen.getByTestId("host-surface");
|
||||
expect(hostSurface).not.toContainElement(portal);
|
||||
});
|
||||
|
||||
it("calls onChange with empty string when 'Use default' is selected", async () => {
|
||||
it("keeps the portaled list interactive for selecting a model and clearing back to default", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
render(
|
||||
<CustomModelDropdown
|
||||
label="Executor Model"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
models={MOCK_MODELS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find and click the "Use default" option (it's always first)
|
||||
const defaultOptions = screen.getAllByText("Use default");
|
||||
// Click the one in the dropdown list (not the trigger)
|
||||
const dropdownDefault = defaultOptions.find((el) => el.classList.contains("model-combobox-option-text--default"));
|
||||
if (dropdownDefault) {
|
||||
await user.click(dropdownDefault);
|
||||
}
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
|
||||
await user.click(within(portal).getByText("Claude Sonnet 4.5"));
|
||||
expect(onChange).toHaveBeenCalledWith("anthropic/claude-sonnet-4-5");
|
||||
|
||||
onChange.mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
const reopenedPortal = await screen.findByTestId("model-combobox-portal");
|
||||
await user.click(within(reopenedPortal).getByText("Use default"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("filters models when typing in search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "openai");
|
||||
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters models by model name", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "opus");
|
||||
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clear button clears filter and restores full list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "openai");
|
||||
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Clear filter"));
|
||||
|
||||
expect(searchInput).toHaveValue("");
|
||||
expect(screen.getByText("4 models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when filter matches nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "xyz123");
|
||||
|
||||
expect(screen.getByText("0 models")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No models match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes dropdown when clicking outside", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<CustomModelDropdown {...defaultProps} />
|
||||
<div data-testid="outside">Outside</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId("outside"));
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes dropdown on Escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens dropdown with arrow down key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
screen.getByLabelText("Test Model").focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates with arrow keys and selects with Enter", async () => {
|
||||
it("closes the portaled dropdown when clicking outside the trigger and menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<CustomModelDropdown {...defaultProps} onChange={onChange} />);
|
||||
|
||||
screen.getByLabelText("Test Model").focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
render(
|
||||
<div>
|
||||
<button type="button">Outside surface</button>
|
||||
<CustomModelDropdown
|
||||
label="Executor Model"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
models={MOCK_MODELS}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
expect(await screen.findByTestId("model-combobox-portal")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Outside surface" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("model-combobox-portal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Navigate down and press Enter
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is disabled when disabled prop is true", () => {
|
||||
render(<CustomModelDropdown {...defaultProps} disabled />);
|
||||
expect(screen.getByLabelText("Test Model")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not open when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} disabled />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has correct ARIA attributes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
const trigger = screen.getByLabelText("Test Model");
|
||||
// Native button element - role is implicit
|
||||
expect(trigger.tagName.toLowerCase()).toBe("button");
|
||||
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("marks selected option with aria-selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} value="openai/gpt-4o" />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
// Find the selected option by looking for the selected class and aria-selected
|
||||
const options = screen.getAllByRole("option");
|
||||
const selectedOption = options.find((opt) => opt.getAttribute("aria-selected") === "true");
|
||||
expect(selectedOption).toHaveTextContent("GPT-4o");
|
||||
});
|
||||
|
||||
it("shows 'Use default' option at the top", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CustomModelDropdown {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Test Model"));
|
||||
|
||||
// The "Use default" option should be visible
|
||||
const defaultOptions = screen.getAllByText("Use default");
|
||||
expect(defaultOptions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows fallback value text when model is not found", () => {
|
||||
render(<CustomModelDropdown {...defaultProps} value="unknown/unknown-model" />);
|
||||
expect(screen.getByText("unknown/unknown-model")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,6 +279,19 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(screen.queryByText("Executor Model")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the shared model dropdown in the portal layer from the inline create surface", async () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
expect(portal).toBeTruthy();
|
||||
expect(portal.classList.contains("model-combobox-dropdown--portal")).toBe(true);
|
||||
expect(document.body.contains(portal)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates executor selection and shows the selected model badge", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
@@ -157,7 +157,7 @@ describe("ModelSelectorTab", () => {
|
||||
expect(screen.queryByTestId(/provider-icon-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens combobox when trigger is clicked", async () => {
|
||||
it("opens combobox in the shared portal layer when trigger is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
@@ -165,6 +165,11 @@ describe("ModelSelectorTab", () => {
|
||||
|
||||
await user.click(getSelector("Executor Model"));
|
||||
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
expect(portal).toBeInTheDocument();
|
||||
expect(portal).toHaveClass("model-combobox-dropdown--portal");
|
||||
expect(document.body).toContainElement(portal);
|
||||
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user