SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
5.7 KB · 132 lines tsx
Raw Blame History
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