Changed "Target Languages" to "Target Languages for Translation" for clarity in the new job form. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
235 lines
7.9 KiB
TypeScript
235 lines
7.9 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { useLanguages } from '../hooks/useLanguages';
|
|
|
|
interface LanguageSelectorProps {
|
|
selectedLanguages: string[];
|
|
onAdd: (langCode: string) => void;
|
|
onRemove: (langCode: string) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function LanguageSelector({
|
|
selectedLanguages,
|
|
onAdd,
|
|
onRemove,
|
|
disabled = false,
|
|
}: LanguageSelectorProps) {
|
|
const { data: languagesData, isLoading, error } = useLanguages();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Get available languages (not already selected), sorted alphabetically
|
|
const availableLanguages = useMemo(() => {
|
|
if (!languagesData?.languages) return [];
|
|
|
|
return Object.entries(languagesData.languages)
|
|
.filter(([code]) => !selectedLanguages.includes(code))
|
|
.sort((a, b) => a[1].localeCompare(b[1]));
|
|
}, [languagesData, selectedLanguages]);
|
|
|
|
// Filter languages by search query
|
|
const filteredLanguages = useMemo(() => {
|
|
if (!searchQuery.trim()) return availableLanguages;
|
|
|
|
const query = searchQuery.toLowerCase();
|
|
return availableLanguages.filter(
|
|
([code, name]) =>
|
|
name.toLowerCase().includes(query) ||
|
|
code.toLowerCase().includes(query)
|
|
);
|
|
}, [availableLanguages, searchQuery]);
|
|
|
|
// Reset highlighted index when filtered results change
|
|
useEffect(() => {
|
|
setHighlightedIndex(0);
|
|
}, [filteredLanguages.length]);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (langCode: string) => {
|
|
onAdd(langCode);
|
|
setSearchQuery('');
|
|
setIsOpen(false);
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!isOpen) {
|
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
setIsOpen(true);
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setHighlightedIndex((prev) =>
|
|
prev < filteredLanguages.length - 1 ? prev + 1 : prev
|
|
);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (filteredLanguages[highlightedIndex]) {
|
|
handleSelect(filteredLanguages[highlightedIndex][0]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
setIsOpen(false);
|
|
break;
|
|
}
|
|
};
|
|
|
|
const getLanguageName = (code: string): string => {
|
|
return languagesData?.languages[code] || code.toUpperCase();
|
|
};
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="text-red-600 text-sm">
|
|
Failed to load languages. Please try again.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Target Languages for Translation
|
|
</label>
|
|
|
|
{/* Selected Languages Tags */}
|
|
{selectedLanguages.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-3">
|
|
{selectedLanguages.map((lang) => (
|
|
<span
|
|
key={lang}
|
|
className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 px-2.5 py-1 rounded-md text-sm"
|
|
>
|
|
{getLanguageName(lang)}
|
|
<button
|
|
type="button"
|
|
onClick={() => onRemove(lang)}
|
|
disabled={disabled}
|
|
className="text-blue-600 hover:text-blue-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
aria-label={`Remove ${getLanguageName(lang)}`}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Searchable Dropdown */}
|
|
<div ref={containerRef} className="relative">
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
setIsOpen(true);
|
|
}}
|
|
onFocus={() => setIsOpen(true)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={disabled || isLoading || availableLanguages.length === 0}
|
|
placeholder={
|
|
isLoading
|
|
? 'Loading languages...'
|
|
: availableLanguages.length === 0
|
|
? 'All languages selected'
|
|
: 'Search languages...'
|
|
}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
aria-label="Search languages"
|
|
aria-expanded={isOpen}
|
|
aria-autocomplete="list"
|
|
/>
|
|
{/* Dropdown arrow */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
disabled={disabled || isLoading || availableLanguages.length === 0}
|
|
className="absolute inset-y-0 right-0 flex items-center pr-2 disabled:cursor-not-allowed"
|
|
tabIndex={-1}
|
|
>
|
|
<svg
|
|
className={`w-5 h-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dropdown Panel */}
|
|
{isOpen && filteredLanguages.length > 0 && (
|
|
<ul
|
|
className="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto"
|
|
role="listbox"
|
|
>
|
|
{filteredLanguages.map(([code, name], index) => (
|
|
<li
|
|
key={code}
|
|
role="option"
|
|
aria-selected={index === highlightedIndex}
|
|
className={`px-3 py-2 cursor-pointer ${
|
|
index === highlightedIndex
|
|
? 'bg-blue-100 text-blue-900'
|
|
: 'hover:bg-gray-100'
|
|
}`}
|
|
onClick={() => handleSelect(code)}
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
|
>
|
|
<span className="font-medium">{name}</span>
|
|
<span className="text-gray-500 ml-2 text-sm">({code})</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* No results message */}
|
|
{isOpen && searchQuery && filteredLanguages.length === 0 && (
|
|
<div className="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg px-3 py-2 text-gray-500 text-sm">
|
|
No languages found matching "{searchQuery}"
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Helper text */}
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{availableLanguages.length > 0
|
|
? `${availableLanguages.length} language${availableLanguages.length !== 1 ? 's' : ''} available`
|
|
: selectedLanguages.length > 0
|
|
? 'All available languages have been selected'
|
|
: ''}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|