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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user