"use client" import { Check, ChevronsUpDown, Search, X } from 'lucide-react'; import * as React from 'react'; import { countries, type TCountryCode } from 'countries-list'; import { cn } from '../../lib/utils'; 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'; import { Flag } from '../specialized/flag'; export interface CountryOption { code: TCountryCode; name: string; native: string; } export type CountrySelectVariant = 'dropdown' | 'inline'; export interface CountrySelectProps { /** Selected country codes (ISO 3166-1 alpha-2) */ value?: string[]; /** Callback when selection changes */ onChange?: (value: string[]) => void; /** Allow multiple selection */ multiple?: boolean; /** Display variant: dropdown (popover) or inline (scrollable list) */ variant?: CountrySelectVariant; /** Placeholder text (default: "Select country...") */ placeholder?: string; /** Search placeholder text (default: "Search...") */ searchPlaceholder?: string; /** Empty results text (default: "No countries found") */ emptyText?: string; /** Additional CSS class */ className?: string; /** Disable the component */ disabled?: boolean; /** Max badges to display (for multiple dropdown mode) */ maxDisplay?: number; /** Custom country name resolver (for i18n) */ getCountryName?: (code: TCountryCode) => string; /** Show native name alongside translated name */ showNativeName?: boolean; /** Filter to specific country codes */ allowedCountries?: TCountryCode[]; /** Exclude specific country codes */ excludedCountries?: TCountryCode[]; /** 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; } /** * Country Select component with SVG flags * * Supports: * - Single and multiple selection * - Dropdown (popover) and inline (scrollable list) variants * - Custom country name translations via getCountryName prop * - Country filtering via allowedCountries/excludedCountries * * Uses ISO 3166-1 alpha-2 country codes. * * @example Single dropdown * ```tsx * setCountry(codes[0])} * /> * ``` * * @example Multiple dropdown * ```tsx * * ``` * * @example Inline list with checkboxes * ```tsx * * ``` * * @example With i18n translations * ```tsx * i18nCountries.getName(code, locale)} * /> * ``` */ export function CountrySelect({ value = [], onChange, multiple = false, variant = 'dropdown', placeholder, searchPlaceholder, emptyText, className, disabled = false, maxDisplay = 3, getCountryName, showNativeName = false, allowedCountries, excludedCountries, maxHeight = 300, showSearch = true, selectedCountLabel = (count: number) => `${count} selected`, moreItemsLabel = (count: number) => `+${count} more`, }: CountrySelectProps) { const [open, setOpen] = React.useState(false) const [search, setSearch] = React.useState("") // Resolve defaults const resolvedPlaceholder = placeholder ?? 'Select country...' const resolvedSearchPlaceholder = searchPlaceholder ?? 'Search...' const resolvedEmptyText = emptyText ?? 'No countries found' // Build country options const allCountries = React.useMemo(() => { let codes = Object.keys(countries) as TCountryCode[]; // Apply filters if (allowedCountries?.length) { codes = codes.filter(code => allowedCountries.includes(code)); } if (excludedCountries?.length) { codes = codes.filter(code => !excludedCountries.includes(code)); } return codes .map((code) => ({ code, name: getCountryName?.(code) ?? countries[code].name, native: countries[code].native, })) .sort((a, b) => a.name.localeCompare(b.name)); }, [getCountryName, allowedCountries, excludedCountries]); const selectedCountries = React.useMemo( () => allCountries.filter((c) => value.includes(c.code)), [allCountries, value] ) const filteredCountries = React.useMemo(() => { if (!search) return allCountries const searchLower = search.toLowerCase() return allCountries.filter( (c) => c.name.toLowerCase().includes(searchLower) || c.native.toLowerCase().includes(searchLower) || c.code.toLowerCase().includes(searchLower) ) }, [allCountries, 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 && selectedCountries.length > 0 && (

{selectedCountLabel(selectedCountries.length)}

)} {/* Country list */}
{filteredCountries.length === 0 ? (

{resolvedEmptyText}

) : ( filteredCountries.map((country) => { const isSelected = value.includes(country.code); return (
); } // Dropdown variant const displayValue = React.useMemo(() => { if (selectedCountries.length === 0) { return {resolvedPlaceholder} } if (!multiple && selectedCountries.length === 1) { const country = selectedCountries[0]!; return (
{country.name}
); } const displayed = selectedCountries.slice(0, maxDisplay) const remaining = selectedCountries.length - maxDisplay return (
{displayed.map((country) => ( {country.name} ))} {remaining > 0 && ( {moreItemsLabel(remaining)} )}
) }, [selectedCountries, maxDisplay, resolvedPlaceholder, disabled, multiple, handleRemove, moreItemsLabel]) return ( { setOpen(isOpen) if (!isOpen) { setSearch("") } }} > {filteredCountries.length === 0 ? ( {resolvedEmptyText} ) : ( {filteredCountries.map((country) => { const isSelected = value.includes(country.code) return ( handleSelect(country.code)} >
{country.name} {showNativeName && country.native !== country.name && ( {country.native} )}
) })}
)}
) } // Re-export types for convenience export type { TCountryCode } from 'countries-list';