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"; export interface CustomModelDropdownProps { models: ModelInfo[]; value: string; onChange: (value: string) => void; placeholder?: string; disabled?: boolean; id?: string; label: string; /** List of favorite provider names in preferred order */ favoriteProviders?: string[]; /** Called when user toggles a provider's favorite status */ onToggleFavorite?: (provider: string) => void; /** List of favorited model identifiers in format "{provider}/{modelId}" */ favoriteModels?: string[]; /** Called when user toggles a model's favorite status */ onToggleModelFavorite?: (modelId: string) => void; } interface DropdownPosition { top: number; left: number; width: number; maxHeight: number; } /** * CustomModelDropdown - A dropdown component combining selection with icon-enhanced provider groups. * * Interaction pattern: * - Closed: Shows trigger button with current selection and provider icon * - 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, value, onChange, placeholder = "Select a model…", disabled = false, id, label, favoriteProviders = [], onToggleFavorite, favoriteModels = [], onToggleModelFavorite, }: CustomModelDropdownProps) { const [isOpen, setIsOpen] = useState(false); const [localFilter, setLocalFilter] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); const [dropdownPosition, setDropdownPosition] = useState(null); const [portalRoot, setPortalRoot] = useState(null); const containerRef = useRef(null); const triggerRef = useRef(null); const dropdownRef = useRef(null); const searchInputRef = useRef(null); const listRef = useRef(null); // Filter models based on local filter text const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]); // Group filtered models by provider and sort by favorites const modelsByProvider = useMemo(() => { return filteredModels.reduce>((acc, m) => { (acc[m.provider] ??= []).push(m); return acc; }, {}); }, [filteredModels]); // Build favorited model entries - models that are in the favoriteModels list and in filteredModels const favoritedModelEntries = useMemo(() => { const result: Array<{ model: ModelInfo; fullId: string }> = []; for (const fullId of favoriteModels) { const slashIdx = fullId.indexOf("/"); if (slashIdx === -1) continue; const provider = fullId.slice(0, slashIdx); const modelId = fullId.slice(slashIdx + 1); const model = filteredModels.find((m) => m.provider === provider && m.id === modelId); if (model) { result.push({ model, fullId }); } } return result; }, [favoriteModels, filteredModels]); // Sort providers: favorites first (in order), then alphabetically // Exclude providers that are already favorited as models (they appear at top as pinned rows) const favoritedProviderSet = new Set(favoriteModels.map((fullId) => { const idx = fullId.indexOf("/"); return idx !== -1 ? fullId.slice(0, idx) : fullId; })); const sortedProviderEntries = useMemo(() => { const entries = Object.entries(modelsByProvider); const favoritesSet = new Set(favoriteProviders); return entries.sort(([a], [b]) => { const aFavorite = favoritesSet.has(a); const bFavorite = favoritesSet.has(b); if (aFavorite && !bFavorite) return -1; if (!aFavorite && bFavorite) return 1; // Both favorites: sort by favoriteProviders order if (aFavorite && bFavorite) { const aIdx = favoriteProviders.indexOf(a); const bIdx = favoriteProviders.indexOf(b); if (aIdx !== bIdx) return aIdx - bIdx; } // Neither favorite: alphabetical return a.localeCompare(b); }); }, [modelsByProvider, favoriteProviders]); // Get current provider from value const currentProvider = useMemo(() => { if (!value) return null; const slashIdx = value.indexOf("/"); return slashIdx === -1 ? null : value.slice(0, slashIdx); }, [value]); // Build list of all selectable options (for keyboard navigation) // Includes favorited models first (as pinned rows), then provider groups const optionsList = useMemo(() => { const options: Array<{ type: "default" | "provider" | "model" | "favorite"; value: string; label: string; provider?: string }> = [ { type: "default", value: "", label: "Use default" }, ]; // Add favorited models as pinned rows first for (const { model, fullId } of favoritedModelEntries) { options.push({ type: "favorite", value: fullId, label: model.name, provider: model.provider, }); } sortedProviderEntries.forEach(([provider, providerModels]) => { options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider }); providerModels.forEach((m) => { options.push({ type: "model", value: `${m.provider}/${m.id}`, label: m.name, provider: m.provider, }); }); }); return options; }, [favoritedModelEntries, sortedProviderEntries]); // Get current selection display text const selectedDisplayText = useMemo(() => { if (!value) return "Use default"; const slashIdx = value.indexOf("/"); if (slashIdx === -1) return value; const provider = value.slice(0, slashIdx); const modelId = value.slice(slashIdx + 1); const model = models.find((m) => m.provider === provider && m.id === modelId); return model?.name || value; }, [value, models]); // Find index of current value in options list const currentValueIndex = useMemo(() => { return optionsList.findIndex((opt) => opt.value === value); }, [optionsList, value]); /** * Get the effective visible viewport dimensions, preferring * `window.visualViewport` when available (accounts for mobile virtual * keyboards, pinch-zoom, etc.) and falling back to `window` dimensions. */ const getEffectiveViewport = useCallback(() => { const vv = window.visualViewport; if (vv && vv.height > 0 && vv.width > 0) { return { width: vv.width, height: vv.height, offsetTop: vv.offsetTop, offsetLeft: vv.offsetLeft, }; } return { width: window.innerWidth, height: window.innerHeight, offsetTop: 0, offsetLeft: 0, }; }, []); const getPreferredDropdownHeight = useCallback(() => { const { height: viewportHeight } = getEffectiveViewport(); const supportsMatchMedia = typeof window.matchMedia === "function"; const isSmallMobile = supportsMatchMedia ? window.matchMedia("(max-width: 640px)").matches : false; const isMobile = supportsMatchMedia ? window.matchMedia("(max-width: 768px)").matches : false; if (viewportHeight <= 0) return 320; if (isSmallMobile) { return Math.min(viewportHeight * 0.6, 360); } if (isMobile) { return Math.min(viewportHeight * 0.7, 420); } return 320; }, [getEffectiveViewport]); const updateDropdownPosition = useCallback(() => { const trigger = triggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); const horizontalPadding = 16; const verticalPadding = 16; const gap = 4; const preferredHeight = getPreferredDropdownHeight(); // Calculate space below and above the trigger, relative to the visible viewport. // On mobile with a virtual keyboard, offsetTop/offsetLeft shift the origin. const triggerBottom = rect.bottom - offsetTop; const triggerTop = rect.top - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); // Determine if we should open upward // Open upward if: not enough space below AND enough space above const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; const maxHeight = Math.max( Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), 160, ); const dropdownWidth = Math.min(rect.width, viewportWidth - horizontalPadding * 2); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - dropdownWidth, ) + offsetLeft; const top = openUpward ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) : Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight); setDropdownPosition({ top, left, width: dropdownWidth, maxHeight, }); }, [getEffectiveViewport, getPreferredDropdownHeight]); useEffect(() => { setPortalRoot(document.body); }, []); // Reset highlighted index when opening useEffect(() => { if (isOpen) { const selectableIndex = optionsList.findIndex( (opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider" ); setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0); } }, [isOpen, optionsList, currentValueIndex]); // Focus search input and position dropdown when opening useEffect(() => { if (!isOpen) return; updateDropdownPosition(); const rafId = requestAnimationFrame(() => searchInputRef.current?.focus()); return () => cancelAnimationFrame(rafId); }, [isOpen, updateDropdownPosition]); // Keep portaled dropdown anchored during viewport and container scrolling. // Also reposition when the visual viewport changes (mobile virtual keyboard, // pinch-zoom, etc.). useEffect(() => { if (!isOpen) return; const handleReposition = () => updateDropdownPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); // Listen for visual viewport changes (virtual keyboard open/close, zoom) const vv = window.visualViewport; if (vv) { vv.addEventListener("resize", handleReposition); vv.addEventListener("scroll", handleReposition); } return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); if (vv) { vv.removeEventListener("resize", handleReposition); vv.removeEventListener("scroll", handleReposition); } }; }, [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", handlePointerDown); return () => document.removeEventListener("mousedown", handlePointerDown); }, [isOpen]); // Keyboard navigation const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { let nextIndex = highlightedIndex; for (let i = 1; i <= optionsList.length; i++) { const idx = (highlightedIndex + i) % optionsList.length; if (optionsList[idx]?.type !== "provider") { nextIndex = idx; break; } } setHighlightedIndex(nextIndex); } break; case "ArrowUp": e.preventDefault(); if (isOpen) { let prevIndex = highlightedIndex; for (let i = 1; i <= optionsList.length; i++) { const idx = (highlightedIndex - i + optionsList.length) % optionsList.length; if (optionsList[idx]?.type !== "provider") { prevIndex = idx; break; } } setHighlightedIndex(prevIndex); } break; case "Enter": e.preventDefault(); if (isOpen) { const option = optionsList[highlightedIndex]; if (option && option.type !== "provider" && option.type !== "favorite") { onChange(option.value); setIsOpen(false); setLocalFilter(""); } } else { setIsOpen(true); } break; case "Escape": e.preventDefault(); setIsOpen(false); setLocalFilter(""); break; case "Tab": if (isOpen) { setIsOpen(false); setLocalFilter(""); } break; } }, [isOpen, highlightedIndex, optionsList, onChange] ); const handleSelect = useCallback( (optionValue: string) => { onChange(optionValue); setIsOpen(false); setLocalFilter(""); }, [onChange] ); const handleClearFilter = useCallback(() => { setLocalFilter(""); searchInputRef.current?.focus(); }, []); const handleTriggerClick = useCallback(() => { if (!disabled) { setIsOpen((prev) => !prev); } }, [disabled]); // Scroll highlighted option into view useEffect(() => { if (isOpen && listRef.current) { const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`); if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") { highlightedEl.scrollIntoView({ block: "nearest" }); } } }, [highlightedIndex, isOpen]); const hasFilter = localFilter.length > 0; const dropdownContent = isOpen && dropdownPosition ? (
setLocalFilter(e.target.value)} onClick={(e) => e.stopPropagation()} /> {hasFilter && ( )}
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
handleSelect("")} onMouseEnter={() => setHighlightedIndex(0)} role="option" aria-selected={value === ""} > Use default
{/* Favorited models as pinned rows */} {favoritedModelEntries.length > 0 && ( <>
{favoritedModelEntries.map(({ model, fullId }, idx) => { const optionIndex = idx + 1; // +1 for "Use default" at index 0 const isHighlighted = highlightedIndex === optionIndex; const isSelected = value === fullId; return (
handleSelect(fullId)} onMouseEnter={() => setHighlightedIndex(optionIndex)} role="option" aria-selected={isSelected} > {model.name} {model.id} {onToggleModelFavorite && ( )}
); })}
)} {sortedProviderEntries.map(([provider, providerModels]) => { const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`); const isFavorite = favoriteProviders.includes(provider); return (
{provider} {onToggleFavorite && ( )}
{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; const isFavorited = favoriteModels.includes(optionValue); return (
handleSelect(optionValue)} onMouseEnter={() => setHighlightedIndex(optionIndex)} role="option" aria-selected={isSelected} > {m.name} {m.id} {onToggleModelFavorite && ( )}
); })}
); })} {filteredModels.length === 0 && hasFilter && (
No models match '{localFilter}'
)}
) : null; return ( <>
{portalRoot && dropdownContent ? createPortal(dropdownContent, portalRoot) : null} ); }