416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { IconChevronDown, IconLoader2, IconRefresh } from '@tabler/icons-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
|
|
|
// Hook for the dropdown position
|
|
function useDropdownPosition(open: boolean, containerRef: React.RefObject<HTMLDivElement | null>) {
|
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 })
|
|
|
|
const updateDropdownPosition = useCallback(() => {
|
|
if (containerRef.current) {
|
|
const rect = containerRef.current.getBoundingClientRect()
|
|
setDropdownPosition({
|
|
top: rect.bottom + window.scrollY + 4,
|
|
left: rect.left + window.scrollX,
|
|
width: rect.width,
|
|
})
|
|
}
|
|
}, [containerRef])
|
|
|
|
// Update the position when the dropdown opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
requestAnimationFrame(() => {
|
|
updateDropdownPosition()
|
|
})
|
|
}
|
|
}, [open, updateDropdownPosition])
|
|
|
|
// Update the position when the window is resized
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
const handleResize = () => {
|
|
updateDropdownPosition()
|
|
}
|
|
|
|
window.addEventListener('resize', handleResize)
|
|
window.addEventListener('scroll', handleResize)
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize)
|
|
window.removeEventListener('scroll', handleResize)
|
|
}
|
|
}, [open, updateDropdownPosition])
|
|
|
|
return { dropdownPosition, updateDropdownPosition }
|
|
}
|
|
|
|
// Components for the different sections of the dropdown
|
|
const ErrorSection = ({ error, onRefresh, t }: { error: string; onRefresh?: () => void; t: (key: string) => string }) => (
|
|
<div className="px-3 py-2 text-sm text-destructive">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-destructive font-medium">{t('common:failedToLoadModels')}</span>
|
|
{onRefresh && (
|
|
<Button
|
|
variant="link"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onRefresh()
|
|
}}
|
|
className="h-6 w-6 p-0 no-underline hover:bg-main-view-fg/10 text-main-view-fg"
|
|
aria-label="Refresh models"
|
|
>
|
|
<IconRefresh className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-main-view-fg/50 mt-0">{error}</div>
|
|
</div>
|
|
)
|
|
|
|
const LoadingSection = ({ t }: { t: (key: string) => string }) => (
|
|
<div className="flex items-center justify-center px-3 py-3 text-sm text-main-view-fg/50">
|
|
<IconLoader2 className="h-4 w-4 animate-spin mr-2 text-main-view-fg/50" />
|
|
<span className="text-sm text-main-view-fg/50">{t('common:loading')}</span>
|
|
</div>
|
|
)
|
|
|
|
const EmptySection = ({ inputValue, t }: { inputValue: string; t: (key: string, options?: Record<string, string>) => string }) => (
|
|
<div className="px-3 py-3 text-sm text-main-view-fg/50 text-center">
|
|
{inputValue.trim() ? (
|
|
<span className="text-main-view-fg/50">{t('common:noModelsFoundFor', { searchValue: inputValue })}</span>
|
|
) : (
|
|
<span className="text-main-view-fg/50">{t('common:noModels')}</span>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
const ModelsList = ({
|
|
filteredModels,
|
|
value,
|
|
highlightedIndex,
|
|
onModelSelect,
|
|
onHighlight
|
|
}: {
|
|
filteredModels: string[]
|
|
value: string
|
|
highlightedIndex: number
|
|
onModelSelect: (model: string) => void
|
|
onHighlight: (index: number) => void
|
|
}) => (
|
|
<>
|
|
{filteredModels.map((model, index) => (
|
|
<div
|
|
key={model}
|
|
data-model={model}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onModelSelect(model)
|
|
}}
|
|
onMouseEnter={() => onHighlight(index)}
|
|
className={cn(
|
|
'cursor-pointer px-3 py-2 hover:bg-main-view-fg/15 hover:shadow-sm transition-all duration-200 text-main-view-fg',
|
|
value === model && 'bg-main-view-fg/12 shadow-sm',
|
|
highlightedIndex === index && 'bg-main-view-fg/20 shadow-md'
|
|
)}
|
|
>
|
|
<span className="text-sm truncate text-main-view-fg">{model}</span>
|
|
</div>
|
|
))}
|
|
</>
|
|
)
|
|
|
|
// Custom hook for keyboard navigation
|
|
function useKeyboardNavigation(
|
|
open: boolean,
|
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
|
models: string[],
|
|
filteredModels: string[],
|
|
highlightedIndex: number,
|
|
setHighlightedIndex: React.Dispatch<React.SetStateAction<number>>,
|
|
onModelSelect: (model: string) => void,
|
|
dropdownRef: React.RefObject<HTMLDivElement | null>
|
|
) {
|
|
|
|
// Scroll to the highlighted element
|
|
useEffect(() => {
|
|
if (highlightedIndex >= 0 && dropdownRef.current) {
|
|
requestAnimationFrame(() => {
|
|
const modelElements = dropdownRef.current?.querySelectorAll('[data-model]')
|
|
const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement
|
|
if (highlightedElement) {
|
|
highlightedElement.scrollIntoView({
|
|
block: 'nearest',
|
|
behavior: 'auto'
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}, [highlightedIndex, dropdownRef])
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
// Open the dropdown with the arrows if closed
|
|
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
if (models.length > 0) {
|
|
e.preventDefault()
|
|
setOpen(true)
|
|
setHighlightedIndex(0)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!open) return
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0))
|
|
break
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1))
|
|
break
|
|
case 'Enter':
|
|
e.preventDefault()
|
|
if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) {
|
|
onModelSelect(filteredModels[highlightedIndex])
|
|
}
|
|
break
|
|
case 'ArrowRight':
|
|
case 'ArrowLeft':
|
|
setOpen(false)
|
|
setHighlightedIndex(-1)
|
|
break
|
|
case 'PageUp':
|
|
e.preventDefault()
|
|
setHighlightedIndex(0)
|
|
break
|
|
case 'PageDown':
|
|
e.preventDefault()
|
|
setHighlightedIndex(filteredModels.length - 1)
|
|
break
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
setOpen(false)
|
|
setHighlightedIndex(-1)
|
|
break
|
|
}
|
|
}, [open, setOpen, models.length, filteredModels, highlightedIndex, setHighlightedIndex, onModelSelect])
|
|
|
|
return { handleKeyDown }
|
|
}
|
|
|
|
type ModelComboboxProps = {
|
|
value: string
|
|
onChange: (value: string) => void
|
|
models: string[]
|
|
loading?: boolean
|
|
error?: string | null
|
|
onRefresh?: () => void
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
className?: string
|
|
}
|
|
|
|
export function ModelCombobox({
|
|
value,
|
|
onChange,
|
|
models,
|
|
loading = false,
|
|
error = null,
|
|
onRefresh,
|
|
placeholder = 'Type or select a model...',
|
|
disabled = false,
|
|
className,
|
|
}: ModelComboboxProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [inputValue, setInputValue] = useState(value)
|
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const dropdownRef = useRef<HTMLDivElement | null>(null)
|
|
const { t } = useTranslation()
|
|
|
|
// Sync input value with prop value
|
|
useEffect(() => {
|
|
setInputValue(value)
|
|
}, [value])
|
|
|
|
// Hook for the dropdown position
|
|
const { dropdownPosition } = useDropdownPosition(open, containerRef)
|
|
|
|
// Optimized model filtering
|
|
const filteredModels = useMemo(() => {
|
|
if (!inputValue.trim()) return models
|
|
const searchValue = inputValue.toLowerCase()
|
|
return models.filter((model) => model.toLowerCase().includes(searchValue))
|
|
}, [models, inputValue])
|
|
|
|
// Reset highlighted index when filtered models change
|
|
useEffect(() => {
|
|
setHighlightedIndex(-1)
|
|
}, [filteredModels])
|
|
|
|
// Close the dropdown when clicking outside
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
const handleClickOutside = (event: Event) => {
|
|
const target = event.target as Node
|
|
const isInsideContainer = containerRef.current?.contains(target)
|
|
const isInsideDropdown = dropdownRef.current?.contains(target)
|
|
|
|
if (!isInsideContainer && !isInsideDropdown) {
|
|
setOpen(false)
|
|
setHighlightedIndex(-1)
|
|
}
|
|
}
|
|
|
|
const events = ['mousedown', 'touchstart']
|
|
events.forEach(eventType => {
|
|
document.addEventListener(eventType, handleClickOutside, { capture: true, passive: true })
|
|
})
|
|
|
|
return () => {
|
|
events.forEach(eventType => {
|
|
document.removeEventListener(eventType, handleClickOutside, { capture: true })
|
|
})
|
|
}
|
|
}, [open])
|
|
|
|
// Cleanup: close the dropdown when the component is unmounted
|
|
useEffect(() => {
|
|
return () => {
|
|
setOpen(false)
|
|
setHighlightedIndex(-1)
|
|
}
|
|
}, [])
|
|
|
|
// Handler for the input change
|
|
const handleInputChange = useCallback((newValue: string) => {
|
|
setInputValue(newValue)
|
|
onChange(newValue)
|
|
|
|
// Open the dropdown if the user types and there are models
|
|
if (newValue.trim() && models.length > 0) {
|
|
setOpen(true)
|
|
} else {
|
|
setOpen(false)
|
|
}
|
|
}, [onChange, models.length])
|
|
|
|
// Handler for the model selection
|
|
const handleModelSelect = useCallback((model: string) => {
|
|
setInputValue(model)
|
|
onChange(model)
|
|
setOpen(false)
|
|
setHighlightedIndex(-1)
|
|
inputRef.current?.focus()
|
|
}, [onChange])
|
|
|
|
// Hook for the keyboard navigation
|
|
const { handleKeyDown } = useKeyboardNavigation(
|
|
open,
|
|
setOpen,
|
|
models,
|
|
filteredModels,
|
|
highlightedIndex,
|
|
setHighlightedIndex,
|
|
handleModelSelect,
|
|
dropdownRef
|
|
)
|
|
|
|
// Handler for the dropdown opening
|
|
const handleDropdownToggle = useCallback(() => {
|
|
inputRef.current?.focus()
|
|
setOpen(!open)
|
|
}, [open])
|
|
|
|
// Handler for the input click
|
|
const handleInputClick = useCallback(() => {
|
|
if (models.length > 0) {
|
|
setOpen(true)
|
|
}
|
|
}, [models.length])
|
|
|
|
return (
|
|
<div className={cn('relative', className)} ref={containerRef}>
|
|
<div className="relative">
|
|
<Input
|
|
ref={inputRef}
|
|
value={inputValue}
|
|
onChange={(e) => handleInputChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onClick={handleInputClick}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
className="pr-8"
|
|
/>
|
|
|
|
{/* Dropdown trigger button */}
|
|
<Button
|
|
variant="link"
|
|
size="sm"
|
|
disabled={disabled}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={handleDropdownToggle}
|
|
className="absolute right-1 top-1/2 h-6 w-6 p-0 -translate-y-1/2 no-underline hover:bg-main-view-fg/10"
|
|
>
|
|
{loading ? (
|
|
<IconLoader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<IconChevronDown className="h-3 w-3 opacity-50" />
|
|
)}
|
|
</Button>
|
|
|
|
{/* Custom dropdown rendered as portal */}
|
|
{open && dropdownPosition.width > 0 && createPortal(
|
|
<div
|
|
ref={dropdownRef}
|
|
className="fixed z-[9999] bg-main-view border border-main-view-fg/10 rounded-md shadow-lg max-h-[300px] overflow-y-auto text-main-view-fg animate-in fade-in-0 zoom-in-95 duration-200"
|
|
style={{
|
|
top: dropdownPosition.top,
|
|
left: dropdownPosition.left,
|
|
width: dropdownPosition.width,
|
|
minWidth: dropdownPosition.width,
|
|
maxWidth: dropdownPosition.width,
|
|
pointerEvents: 'auto',
|
|
}}
|
|
data-dropdown="model-combobox"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onWheel={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Error state */}
|
|
{error && <ErrorSection error={error} onRefresh={onRefresh} t={t} />}
|
|
|
|
{/* Loading state */}
|
|
{loading && <LoadingSection t={t} />}
|
|
|
|
{/* Models list */}
|
|
{!loading && !error && (
|
|
filteredModels.length === 0 ? (
|
|
<EmptySection inputValue={inputValue} t={t} />
|
|
) : (
|
|
<ModelsList
|
|
filteredModels={filteredModels}
|
|
value={value}
|
|
highlightedIndex={highlightedIndex}
|
|
onModelSelect={handleModelSelect}
|
|
onHighlight={setHighlightedIndex}
|
|
/>
|
|
)
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|