"use client" import { Check, ChevronsUpDown, Search, X } from 'lucide-react'; import * as React from 'react'; import { languages, type TLanguageCode } from 'countries-list'; import { cn } from '../../lib/utils'; import { LanguageFlag } from '../specialized/flag'; import { Badge } from '../data/badge'; import { Button } from '../forms/button'; import { Checkbox } from '../forms/checkbox'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../navigation/command'; import { Input } from '../forms/input'; import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover'; import { ScrollArea } from '../layout/scroll-area'; export interface LanguageOption { code: TLanguageCode; name: string; native: string; } export type LanguageSelectVariant = 'dropdown' | 'inline'; /** Trigger size for the dropdown variant — mirrors Button/Input sizing. * `default` = 40px form control; `sm` = 32px compact (settings rows, chips). */ export type LanguageSelectSize = 'default' | 'sm'; export interface LanguageSelectProps { /** Selected language codes (ISO 639-1) */ value?: string[]; /** Callback when selection changes */ onChange?: (value: string[]) => void; /** Allow multiple selection */ multiple?: boolean; /** Display variant: dropdown (popover) or inline (scrollable list) */ variant?: LanguageSelectVariant; /** Trigger size for the dropdown variant (default | sm). Mirrors Button. */ size?: LanguageSelectSize; /** Placeholder text (default: "Select language...") */ placeholder?: string; /** Search placeholder text (default: "Search...") */ searchPlaceholder?: string; /** Empty results text (default: "No languages found") */ emptyText?: string; /** Additional CSS class */ className?: string; /** Disable the component */ disabled?: boolean; /** Max badges to display (for multiple dropdown mode) */ maxDisplay?: number; /** Custom language name resolver (for i18n) */ getLanguageName?: (code: TLanguageCode) => string; /** Show native name alongside translated name */ showNativeName?: boolean; /** Filter to specific language codes */ allowedLanguages?: TLanguageCode[]; /** Exclude specific language codes */ excludedLanguages?: TLanguageCode[]; /** Show country-flag SVG (default: true) */ showFlag?: boolean; /** Show language code (default: false) */ showCode?: boolean; /** Max height for inline variant */ maxHeight?: number; /** Show search input */ showSearch?: boolean; /** Custom label for selected count (receives count as param). Example: (count) => `${count} selected` */ selectedCountLabel?: (count: number) => string; /** Custom label for "more items" badge (receives count as param). Example: (count) => `+${count} more` */ moreItemsLabel?: (count: number) => string; } /** * Language Select component * * Supports: * - Single and multiple selection * - Dropdown (popover) and inline (scrollable list) variants * - Custom language name translations via getLanguageName prop * - Language filtering via allowedLanguages/excludedLanguages * * Uses ISO 639-1 language codes. * * @example Single dropdown * ```tsx * setLanguage(codes[0])} * /> * ``` * * @example Multiple dropdown * ```tsx * * ``` * * @example Inline list with checkboxes * ```tsx * * ``` * * @example With i18n translations * ```tsx * i18nLanguages.getName(code, locale)} * /> * ``` */ export function LanguageSelect({ value = [], onChange, multiple = false, variant = 'dropdown', size = 'default', placeholder, searchPlaceholder, emptyText, className, disabled = false, maxDisplay = 3, getLanguageName, showNativeName = false, allowedLanguages, excludedLanguages, showFlag = true, showCode = false, maxHeight = 300, showSearch = true, selectedCountLabel = (count: number) => `${count} selected`, moreItemsLabel = (count: number) => `+${count} more`, }: LanguageSelectProps) { const [open, setOpen] = React.useState(false) const [search, setSearch] = React.useState("") // Resolve defaults const resolvedPlaceholder = placeholder ?? 'Select language...' const resolvedSearchPlaceholder = searchPlaceholder ?? 'Search...' const resolvedEmptyText = emptyText ?? 'No languages found' // Build language options const allLanguages = React.useMemo(() => { let codes = Object.keys(languages) as TLanguageCode[]; // Apply filters if (allowedLanguages?.length) { codes = codes.filter(code => allowedLanguages.includes(code)); } if (excludedLanguages?.length) { codes = codes.filter(code => !excludedLanguages.includes(code)); } return codes .map((code) => ({ code, name: getLanguageName?.(code) ?? languages[code].name, native: languages[code].native, })) .sort((a, b) => a.name.localeCompare(b.name)); }, [getLanguageName, allowedLanguages, excludedLanguages]); const selectedLanguages = React.useMemo( () => allLanguages.filter((l) => value.includes(l.code)), [allLanguages, value] ) const filteredLanguages = React.useMemo(() => { if (!search) return allLanguages const searchLower = search.toLowerCase() return allLanguages.filter( (l) => l.name.toLowerCase().includes(searchLower) || l.native.toLowerCase().includes(searchLower) || l.code.toLowerCase().includes(searchLower) ) }, [allLanguages, search]) const handleSelect = React.useCallback((code: string) => { if (multiple) { const newValue = value.includes(code) ? value.filter((v) => v !== code) : [...value, code] onChange?.(newValue) } else { onChange?.([code]) if (variant === 'dropdown') { setOpen(false) } } }, [multiple, value, onChange, variant]) const handleRemove = React.useCallback((code: string, e: React.MouseEvent) => { e.stopPropagation() onChange?.(value.filter((v) => v !== code)) }, [value, onChange]) // Inline variant if (variant === 'inline') { return (
{/* Search input */} {showSearch && (
setSearch(e.target.value)} className="pl-9" disabled={disabled} />
)} {/* Selected count */} {multiple && selectedLanguages.length > 0 && (

{selectedCountLabel(selectedLanguages.length)}

)} {/* Language list */}
{filteredLanguages.length === 0 ? (

{resolvedEmptyText}

) : ( filteredLanguages.map((language) => { const isSelected = value.includes(language.code); return (
!disabled && handleSelect(language.code)} onKeyDown={(e) => { if (!disabled && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); handleSelect(language.code); } }} className={cn( 'flex items-center gap-3 px-3 py-2 rounded-[var(--radius-sm)] cursor-pointer', 'hover:bg-accent/50 transition-colors', isSelected && 'bg-primary/5', disabled && 'opacity-50 cursor-not-allowed', )} > {multiple ? ( !disabled && handleSelect(language.code)} disabled={disabled} onClick={(e) => e.stopPropagation()} /> ) : (
{isSelected &&
}
)} {showFlag && ( )}
{language.name} {showNativeName && language.native !== language.name && ( {language.native} )}
{showCode && ( {language.code} )}
); }) )}
); } // Dropdown variant const displayValue = React.useMemo(() => { if (selectedLanguages.length === 0) { return {resolvedPlaceholder} } if (!multiple && selectedLanguages.length === 1) { const language = selectedLanguages[0]!; return (
{showFlag && } {showCode && {language.code}} {language.name}
); } const displayed = selectedLanguages.slice(0, maxDisplay) const remaining = selectedLanguages.length - maxDisplay return (
{displayed.map((language) => ( {showFlag && } {showCode && {language.code}} {language.name} ))} {remaining > 0 && ( {moreItemsLabel(remaining)} )}
) }, [selectedLanguages, maxDisplay, resolvedPlaceholder, disabled, multiple, handleRemove, moreItemsLabel, showFlag, showCode]) return ( { setOpen(isOpen) if (!isOpen) { setSearch("") } }} > {/* The list must stay readable even when the trigger is a narrow status-bar/settings pill: grow to the trigger width but never below a legible min, so language names aren't clipped to "En…". */} {showSearch && ( )} {filteredLanguages.length === 0 ? ( {resolvedEmptyText} ) : ( {filteredLanguages.map((language) => { const isSelected = value.includes(language.code) return ( handleSelect(language.code)} > {showFlag && ( )}
{language.name} {showNativeName && language.native !== language.name && ( {language.native} )}
{showCode && ( {language.code} )}
) })}
)}
) } // Re-export types for convenience export type { TLanguageCode } from 'countries-list'; export { LANGUAGE_TO_COUNTRY } from '../specialized/flag';