mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-09-24 15:00:31 +08:00
Improve language filtering
This commit is contained in:
@@ -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;
|
||||
<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"
|
||||
onClick={() => {
|
||||
onSelectLanguage(language);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
{language}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user