import { useState, useRef, useEffect, useMemo } from 'react'; import { iso6393, Language } from 'iso-639-3'; import './LanguageCombobox.css'; // Build language lookup maps const languagesByCode = new Map(); for (const lang of iso6393) { languagesByCode.set(lang.iso6393, lang); } // Format for display: "English (eng)" or "eng" for unknown export function formatLanguageOption(code: string): string { const lang = languagesByCode.get(code); if (lang) { return `${lang.name} (${code})`; } return code; } interface LanguageComboboxProps { value: string; onChange: (code: string) => void; disabled?: boolean; placeholder?: string; } export function LanguageCombobox({ value, onChange, disabled = false, placeholder = 'Search language...', }: LanguageComboboxProps) { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(''); const [highlightedIndex, setHighlightedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); // Filter languages based on search const filteredLanguages = useMemo(() => { const searchLower = search.toLowerCase().trim(); if (!searchLower) { // Show common languages first when no search const common = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa', 'ita', 'por', 'rus']; const commonLangs = common .map(code => languagesByCode.get(code)) .filter((l): l is typeof iso6393[0] => l !== undefined); return commonLangs; } return iso6393.filter((lang: Language) => { const nameMatch = lang.name.toLowerCase().includes(searchLower); const codeMatch = lang.iso6393.toLowerCase().includes(searchLower); const iso1Match = lang.iso6391 && lang.iso6391.toLowerCase().includes(searchLower); return nameMatch || codeMatch || iso1Match; }).slice(0, 50); // Limit results for performance }, [search]); // Check if search is a valid custom 3-letter code const isValidCustomCode = useMemo(() => { const trimmed = search.trim().toLowerCase(); return trimmed.length === 3 && /^[a-z]{3}$/.test(trimmed) && !languagesByCode.has(trimmed); }, [search]); // Reset highlight when filtered list changes useEffect(() => { setHighlightedIndex(0); }, [filteredLanguages.length]); // Scroll highlighted item into view useEffect(() => { if (isOpen && listRef.current) { const highlighted = listRef.current.querySelector('.highlighted'); if (highlighted) { highlighted.scrollIntoView({ block: 'nearest' }); } } }, [highlightedIndex, isOpen]); const handleSelect = (code: string) => { onChange(code); setSearch(''); setIsOpen(false); inputRef.current?.blur(); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!isOpen) { if (e.key === 'ArrowDown' || e.key === 'Enter') { setIsOpen(true); e.preventDefault(); } return; } const totalItems = filteredLanguages.length + (isValidCustomCode ? 1 : 0); switch (e.key) { case 'ArrowDown': e.preventDefault(); setHighlightedIndex(prev => (prev + 1) % totalItems); break; case 'ArrowUp': e.preventDefault(); setHighlightedIndex(prev => (prev - 1 + totalItems) % totalItems); break; case 'Enter': e.preventDefault(); if (isValidCustomCode && highlightedIndex === filteredLanguages.length) { handleSelect(search.trim().toLowerCase()); } else if (filteredLanguages[highlightedIndex]) { handleSelect(filteredLanguages[highlightedIndex].iso6393); } break; case 'Escape': setIsOpen(false); setSearch(''); inputRef.current?.blur(); break; } }; const displayValue = isOpen ? search : formatLanguageOption(value); return (
{ setSearch(e.target.value); setIsOpen(true); }} onFocus={() => { setSearch(''); setIsOpen(true); }} onBlur={() => { // Delay to allow click on option setTimeout(() => { setIsOpen(false); setSearch(''); }, 200); }} onKeyDown={handleKeyDown} disabled={disabled} placeholder={placeholder} /> ▼ {isOpen && (
{filteredLanguages.map((lang: Language, index: number) => (
handleSelect(lang.iso6393)} onMouseEnter={() => setHighlightedIndex(index)} > {lang.name} {lang.iso6393}
))} {isValidCustomCode && (
handleSelect(search.trim().toLowerCase())} onMouseEnter={() => setHighlightedIndex(filteredLanguages.length)} > Use custom code {search.trim().toLowerCase()}
)} {filteredLanguages.length === 0 && !isValidCustomCode && (
No languages found. Type a 3-letter code to add custom.
)}
)}
); }