|
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; |
|
|
|
|
import { useOutsideClick } from '../../hooks/use-outside-click'; |
|
|
|
|
import { httpGet } from '../../lib/http'; |
|
|
|
|
import { useToast } from '../../hooks/use-toast'; |
|
|
|
|
import { ChevronDown, X } from 'lucide-react'; |
|
|
|
|
import { ChevronDown, Search, X } from 'lucide-react'; |
|
|
|
|
|
|
|
|
|
type SelectLanguagesProps = { |
|
|
|
|
projectId: string; |
|
|
|
@ -14,10 +14,44 @@ export function SelectLanguages(props: SelectLanguagesProps) { |
|
|
|
|
const { projectId, onSelectLanguage, selectedLanguage } = props; |
|
|
|
|
|
|
|
|
|
const dropdownRef = useRef<HTMLDivElement>(null); |
|
|
|
|
const searchInputRef = useRef<HTMLInputElement>(null); |
|
|
|
|
const optionsRef = useRef<HTMLDivElement>(null); |
|
|
|
|
const toast = useToast(); |
|
|
|
|
|
|
|
|
|
const [distinctLanguages, setDistinctLanguages] = useState<string[]>([]); |
|
|
|
|
const [isOpen, setIsOpen] = useState(false); |
|
|
|
|
const [searchQuery, setSearchQuery] = useState(''); |
|
|
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0); |
|
|
|
|
|
|
|
|
|
const filteredLanguages = distinctLanguages.filter((language) => |
|
|
|
|
language.toLowerCase().includes(searchQuery.toLowerCase()), |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
// Handle scrolling of highlighted option into view
|
|
|
|
|
useEffect(() => { |
|
|
|
|
if (!isOpen || !optionsRef.current) { |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const options = optionsRef.current.getElementsByTagName('button'); |
|
|
|
|
const highlightedOption = options[highlightedIndex]; |
|
|
|
|
if (!highlightedOption) { |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const containerRect = optionsRef.current.getBoundingClientRect(); |
|
|
|
|
const optionRect = highlightedOption.getBoundingClientRect(); |
|
|
|
|
|
|
|
|
|
const isAbove = optionRect.top < containerRect.top; |
|
|
|
|
const isBelow = optionRect.bottom > containerRect.bottom; |
|
|
|
|
|
|
|
|
|
if (isAbove || isBelow) { |
|
|
|
|
highlightedOption.scrollIntoView({ |
|
|
|
|
block: 'nearest', |
|
|
|
|
behavior: 'instant', |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
}, [highlightedIndex, isOpen]); |
|
|
|
|
|
|
|
|
|
const loadDistinctLanguages = async () => { |
|
|
|
|
const { response, error } = await httpGet<string[]>( |
|
|
|
@ -34,53 +68,124 @@ export function SelectLanguages(props: SelectLanguagesProps) { |
|
|
|
|
|
|
|
|
|
useOutsideClick(dropdownRef, () => { |
|
|
|
|
setIsOpen(false); |
|
|
|
|
setSearchQuery(''); |
|
|
|
|
setHighlightedIndex(0); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
loadDistinctLanguages().finally(() => {}); |
|
|
|
|
}, []); |
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
if (isOpen && searchInputRef.current) { |
|
|
|
|
searchInputRef.current.focus(); |
|
|
|
|
} |
|
|
|
|
}, [isOpen]); |
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => { |
|
|
|
|
switch (e.key) { |
|
|
|
|
case 'ArrowDown': |
|
|
|
|
e.preventDefault(); |
|
|
|
|
setHighlightedIndex((prev) => |
|
|
|
|
prev >= filteredLanguages.length - 1 ? 0 : prev + 1, |
|
|
|
|
); |
|
|
|
|
break; |
|
|
|
|
case 'ArrowUp': |
|
|
|
|
e.preventDefault(); |
|
|
|
|
setHighlightedIndex((prev) => |
|
|
|
|
prev <= 0 ? filteredLanguages.length - 1 : prev - 1, |
|
|
|
|
); |
|
|
|
|
break; |
|
|
|
|
case 'Enter': |
|
|
|
|
e.preventDefault(); |
|
|
|
|
if (filteredLanguages[highlightedIndex]) { |
|
|
|
|
onSelectLanguage(filteredLanguages[highlightedIndex]); |
|
|
|
|
setIsOpen(false); |
|
|
|
|
setSearchQuery(''); |
|
|
|
|
setHighlightedIndex(0); |
|
|
|
|
} |
|
|
|
|
break; |
|
|
|
|
case 'Escape': |
|
|
|
|
setIsOpen(false); |
|
|
|
|
setSearchQuery(''); |
|
|
|
|
setHighlightedIndex(0); |
|
|
|
|
break; |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div className="relative flex"> |
|
|
|
|
<button |
|
|
|
|
className="flex items-center gap-1 rounded-md border border-gray-300 py-1.5 pl-3 pr-2 text-xs font-medium text-gray-900" |
|
|
|
|
onClick={() => setIsOpen(!isOpen)} |
|
|
|
|
> |
|
|
|
|
{selectedLanguage || 'Select Language'} |
|
|
|
|
|
|
|
|
|
<ChevronDown className="ml-1 h-4 w-4" /> |
|
|
|
|
</button> |
|
|
|
|
{selectedLanguage && ( |
|
|
|
|
<div className="relative"> |
|
|
|
|
<button |
|
|
|
|
className="ml-1 text-red-500 text-xs border border-red-500 rounded-md px-2 py-1" |
|
|
|
|
onClick={() => onSelectLanguage('')} |
|
|
|
|
className="flex items-center gap-1 rounded-md border border-gray-300 py-1.5 pl-3 pr-2 text-xs font-medium text-gray-900" |
|
|
|
|
onClick={() => setIsOpen(!isOpen)} |
|
|
|
|
> |
|
|
|
|
Clear |
|
|
|
|
{selectedLanguage || 'Select Language'} |
|
|
|
|
<ChevronDown className="ml-1 h-4 w-4" /> |
|
|
|
|
</button> |
|
|
|
|
)} |
|
|
|
|
{selectedLanguage && ( |
|
|
|
|
<button |
|
|
|
|
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white hover:bg-red-600" |
|
|
|
|
onClick={(e) => { |
|
|
|
|
e.stopPropagation(); |
|
|
|
|
onSelectLanguage(''); |
|
|
|
|
}} |
|
|
|
|
> |
|
|
|
|
<X className="size-3" strokeWidth={2.5} /> |
|
|
|
|
<span className="sr-only">Clear selection</span> |
|
|
|
|
</button> |
|
|
|
|
)} |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
{isOpen && ( |
|
|
|
|
<div |
|
|
|
|
className="absolute right-0 top-full z-10 w-full min-w-[200px] max-w-[200px] translate-y-1.5 overflow-hidden rounded-md border border-gray-300 bg-white p-1 shadow-lg" |
|
|
|
|
ref={dropdownRef} |
|
|
|
|
onKeyDown={handleKeyDown} |
|
|
|
|
> |
|
|
|
|
{distinctLanguages.map((language) => { |
|
|
|
|
const isSelected = selectedLanguage === language; |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<button |
|
|
|
|
key={language} |
|
|
|
|
className="flex w-full items-center rounded-md px-4 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100 aria-selected:bg-gray-100" |
|
|
|
|
onClick={() => { |
|
|
|
|
onSelectLanguage(language); |
|
|
|
|
setIsOpen(false); |
|
|
|
|
}} |
|
|
|
|
aria-selected={isSelected} |
|
|
|
|
> |
|
|
|
|
{language} |
|
|
|
|
</button> |
|
|
|
|
); |
|
|
|
|
})} |
|
|
|
|
<div className="relative mb-1 px-1"> |
|
|
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" /> |
|
|
|
|
<input |
|
|
|
|
ref={searchInputRef} |
|
|
|
|
type="text" |
|
|
|
|
className="w-full rounded-md border border-gray-200 py-1.5 pl-9 pr-3 text-sm focus:border-gray-300 focus:outline-none" |
|
|
|
|
placeholder="Search languages..." |
|
|
|
|
value={searchQuery} |
|
|
|
|
onChange={(e) => { |
|
|
|
|
setSearchQuery(e.target.value); |
|
|
|
|
setHighlightedIndex(0); |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
<div ref={optionsRef} className="max-h-[200px] overflow-y-auto"> |
|
|
|
|
{filteredLanguages.map((language, index) => { |
|
|
|
|
const isSelected = selectedLanguage === language; |
|
|
|
|
const isHighlighted = index === highlightedIndex; |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<button |
|
|
|
|
key={language} |
|
|
|
|
className={`flex w-full items-center rounded-md px-4 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100 aria-selected:bg-gray-100 ${ |
|
|
|
|
isHighlighted ? 'bg-gray-100' : '' |
|
|
|
|
}`}
|
|
|
|
|
onClick={() => { |
|
|
|
|
onSelectLanguage(language); |
|
|
|
|
setIsOpen(false); |
|
|
|
|
setSearchQuery(''); |
|
|
|
|
setHighlightedIndex(0); |
|
|
|
|
}} |
|
|
|
|
aria-selected={isSelected} |
|
|
|
|
> |
|
|
|
|
{language} |
|
|
|
|
</button> |
|
|
|
|
); |
|
|
|
|
})} |
|
|
|
|
{filteredLanguages.length === 0 && ( |
|
|
|
|
<div className="px-4 py-2 text-sm text-gray-500"> |
|
|
|
|
No languages found |
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
)} |
|
|
|
|
</div> |
|
|
|
|