TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use client";2import * as React from "react";3import * as Popover from "@radix-ui/react-popover";4import { Check, ChevronDown, Globe, Search } from "lucide-react";5import { cn } from "@/lib/utils";6import type { CountryOption } from "./types";78/**9 * Searchable country combobox (Radix Popover + listbox). Value "" means "Any country".10 * Keyboard: type to filter, ArrowUp/Down to move, Enter to select, Escape to close.11 */12export function CountrySelect({ value, onChange, countries, id, disabled }: { value: string; onChange: (code: string) => void; countries: CountryOption[]; id?: string; disabled?: boolean }) {13 const [open, setOpen] = React.useState(false);14 const [query, setQuery] = React.useState("");15 const [active, setActive] = React.useState(0);16 const listRef = React.useRef<HTMLUListElement | null>(null);17 const listboxId = React.useId();1819 const handleOpenChange = (next: boolean) => {20 setOpen(next);21 if (!next) {22 setQuery("");23 setActive(0);24 }25 };2627 const options = React.useMemo(() => {28 const q = query.trim().toLowerCase();29 const all: CountryOption[] = [{ code: "", name: "Any country" }, ...countries];30 if (!q) return all;31 return all.filter((c) => c.code === "" || c.name.toLowerCase().includes(q) || c.code.toLowerCase() === q || c.code.toLowerCase().startsWith(q));32 }, [countries, query]);3334 React.useEffect(() => {35 const node = listRef.current?.children[active] as HTMLElement | undefined;36 node?.scrollIntoView({ block: "nearest" });37 }, [active]);3839 const selected = countries.find((c) => c.code === value);40 const select = (code: string) => {41 onChange(code);42 setOpen(false);43 };4445 return (46 <Popover.Root open={open} onOpenChange={handleOpenChange}>47 <Popover.Trigger asChild>48 <button49 type="button"50 id={id}51 role="combobox"52 aria-expanded={open}53 aria-haspopup="listbox"54 aria-controls={listboxId}55 disabled={disabled}56 className={cn(57 "flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-bg px-3 py-1 text-sm shadow-xs transition-colors hover:border-border-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/25 disabled:cursor-not-allowed disabled:opacity-50",58 )}59 >60 <span className="flex min-w-0 items-center gap-2 truncate">61 <Globe className="size-3.5 shrink-0 text-fg-subtle" aria-hidden />62 {selected ? (63 <>64 <span className="font-mono text-[12.5px] text-fg-muted">{selected.code}</span>65 <span className="truncate">{selected.name}</span>66 </>67 ) : (68 <span className="text-fg-muted">Any country</span>69 )}70 </span>71 <ChevronDown className="size-4 shrink-0 text-fg-subtle" aria-hidden />72 </button>73 </Popover.Trigger>74 <Popover.Portal>75 <Popover.Content align="start" sideOffset={4} className="z-50 w-[var(--radix-popover-trigger-width)] min-w-[16rem] overflow-hidden rounded-md border border-border bg-bg-elevated text-fg shadow-md data-[state=open]:animate-fade-in" onOpenAutoFocus={(e) => e.preventDefault()}>76 <div className="flex items-center gap-2 border-b border-border px-2.5">77 <Search className="size-3.5 shrink-0 text-fg-subtle" aria-hidden />78 <input79 autoFocus80 value={query}81 onChange={(e) => {82 setQuery(e.target.value);83 setActive(0);84 }}85 placeholder="Search countries…"86 aria-label="Search countries"87 aria-controls={listboxId}88 aria-activedescendant={options[active] ? `country-opt-${options[active].code || "any"}` : undefined}89 className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-fg-subtle"90 onKeyDown={(e) => {91 if (e.key === "ArrowDown") {92 e.preventDefault();93 setActive((a) => Math.min(a + 1, options.length - 1));94 } else if (e.key === "ArrowUp") {95 e.preventDefault();96 setActive((a) => Math.max(a - 1, 0));97 } else if (e.key === "Enter") {98 e.preventDefault();99 const o = options[active];100 if (o) select(o.code);101 }102 }}103 />104 </div>105 <ul ref={listRef} id={listboxId} role="listbox" aria-label="Countries" className="max-h-64 overflow-y-auto p-1 scrollbar-thin">106 {options.length === 0 ? <li className="px-2 py-3 text-center text-xs text-fg-subtle">No match.</li> : null}107 {options.map((c, i) => {108 const isSel = c.code === value;109 return (110 <li111 key={c.code || "any"}112 id={`country-opt-${c.code || "any"}`}113 role="option"114 aria-selected={isSel}115 onMouseEnter={() => setActive(i)}116 onMouseDown={(e) => e.preventDefault()}117 onClick={() => select(c.code)}118 className={cn("relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm", i === active && "bg-bg-muted")}119 >120 <span className="w-7 shrink-0 font-mono text-[12px] text-fg-subtle">{c.code || "—"}</span>121 <span className="truncate">{c.name}</span>122 {isSel ? <Check className="absolute right-2 size-3.5" aria-hidden /> : null}123 </li>124 );125 })}126 </ul>127 </Popover.Content>128 </Popover.Portal>129 </Popover.Root>130 );131}132