{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "currency-select",
  "title": "Currency Select",
  "description": "A currency picker: every ISO 4217 currency the runtime knows, named in the reader's own language, sorted the way that language sorts, and searchable by local name, English name, three-letter code or symbol. Reach for it wherever a form has to settle which money an amount is in: the currency on a price, plan or product; the billing currency on a subscription or invoice; the currency of an expense, receipt or reimbursement; the payout, remittance or bank-transfer currency on a payments or Connect onboarding form; the display currency on a multi-currency store or a pricing table; the base and quote currency on an exchange-rate or conversion field; the ledger currency in accounting, bookkeeping and budgeting; and the \"default currency\" row in workspace, organisation and account settings. Common asks it answers: \"currency select\", \"currency picker\", \"currency dropdown\", \"currency selector react\", \"ISO 4217 select\", \"currency code select\", \"searchable currency select\", \"currency combobox\", \"list of currencies react\", \"currency select with symbols\", \"shadcn currency select\", \"shadcn currency picker\", \"react-select currency alternative\", \"currency autocomplete\", \"select currency for invoice\", \"multi-currency dropdown\". Official shadcn/ui has no currency, money or price item of any kind — and nothing Intl-aware at all: its select, native-select and combobox are empty shells that know nothing about money, so the data and the arithmetic are on you, and both are where hand-rolled versions go wrong. This one carries no currency table: it reads the codes from Intl.supportedValuesOf(\"currency\") and the names from Intl.DisplayNames, which matters more for currencies than it would for countries, because currencies get replaced — ZWG (Zimbabwean Gold) arrived in 2024, XCG (Caribbean guilder) in 2025, SLE replaced SLL in 2022, and a table baked into a component in 2023 is missing all three today while the browser's own list is not. The curation that matters is small and named: XDR (IMF Special Drawing Rights) and XSU (Sucre) are units of account for settling between central banks, not money anyone is paid in, so they are excluded — while XAF, XOF, XPF and XCD are currencies millions are paid in daily and survive the cut, which is why dropping the whole X prefix (the obvious shortcut) is wrong. Historical codes stay and stay labelled, because the runtime dates them for you — SLL arrives as \"Sierra Leonean Leone (1964—2022)\" — so a 2021 invoice can still render in the currency it was written in; pass `currencies` when a field should only offer what you accept today. The export that prevents the expensive bug is getCurrencyFractionDigits(): decimal places are not 2 everywhere — they are 0 for JPY, KRW, VND, ISK and some thirty others, and 3 for the Gulf dinars (BHD, JOD, KWD, LYD, OMR, TND), about a quarter of the list — and payment APIs (Stripe, Adyen, PayPal) take the amount in the currency's minor unit, so `Math.round(amount * 10 ** getCurrencyFractionDigits(code))` is the conversion and hardcoding 2 there bills a Japanese customer a hundred times what they agreed to. Pairs directly with pulld's currency-input: feed the chosen code to its `currency` prop and the amount field picks up the same symbol, grouping and precision. Sorting goes through Intl.Collator, the difference between a usable list and a broken one: a plain sort() orders by code point, which drops every accented name — \"São Tomé & Príncipe Dobra\", \"Costa Rican Colón\" — below Z at the very bottom where nobody scrolls. The filter reads four faces, because a person has four ways to name money: the local name, the English name (typed constantly on non-English sites, because it is what the pricing page and the processor say), the code (which is what the API takes, so it is what a developer has in their head), and the symbol — and the symbol has to be read before folding, because fold(\"¥\") is the empty string and a filter that quietly shows all 160 rows reads as broken. An exact three-letter code wins outright, and symbols stay qualified rather than narrow, so \"$\" reaches the US Dollar while AUD, CAD, NZD and HKD keep their A$, CA$, NZ$ and HK$ instead of collapsing into four identical dollar signs. A real combobox, not a styled div: the trigger is a type=\"button\" with role=\"combobox\" and aria-expanded, the panel is a listbox driven by aria-activedescendant, arrow keys, Home, End, Enter and Escape all work, the highlight scrolls itself into view, opening a field that already says Japanese Yen starts on Japanese Yen, and an outside press closes it. Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`), always emitting the ISO 4217 code and never a name or a symbol, so what you store survives the reader switching language; `name` adds a hidden input so it submits with a native form; a stored code outside a narrowed `currencies` still shows its own name instead of silently reading as \"nothing chosen\", which is what keeps an old ledger row from being lost on the next save; `priority` pins the two or three currencies most of your revenue is in above the alphabet; `symbols` turns the glyph off; and getCurrencyName() and getCurrencySymbol() are exported so an invoice header or a pricing table spells the currency exactly the way the picker did. Styled entirely with shadcn tokens (input, ring, accent, popover, muted-foreground), so it follows light and dark mode, and it ships zero dependencies — no currency-data package, no icon package, one file.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/currency-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Codes `Intl` offers that are not money anyone can be paid in.\n *\n * `Intl.supportedValuesOf(\"currency\")` is close to a clean ISO 4217 list — closer than the region\n * list `country-select` had to curate, which happily names the European Union and the world. Only\n * two entries here are not a currency: `XDR`, the IMF's Special Drawing Rights, and `XSU`, the\n * Sucre, both units of account that exist to settle balances between central banks. Neither is a\n * thing to price a subscription in, so neither is offered.\n *\n * Nothing else is excluded, deliberately. Historical codes stay — `SLL`, `ZWL`, `HRK`, `CUC` — and\n * they stay labelled, because the runtime spells the range out for you: `SLL` arrives as \"Sierra\n * Leonean Leone (1964—2022)\". An invoice written in 2021 is still denominated in the currency it\n * was written in, and a ledger that cannot name it cannot show it. Pass `currencies` when a field\n * should only offer what you actually accept today.\n */\nexport const NON_TENDER_CURRENCY_CODES = [\"XDR\", \"XSU\"] as const\n\n/**\n * The ISO 4217 codes the runtime knows, minus the two above.\n *\n * Read at runtime rather than shipped as a table, and that is a bigger deal for currencies than it\n * would be for countries: currencies get replaced. `ZWG` (Zimbabwean Gold) arrived in 2024, `XCG`\n * (Caribbean guilder) in 2025, `SLE` replaced `SLL` in 2022. A table baked into a component in 2023\n * is missing all three today; the browser's own list is not, because the browser updates it.\n *\n * Reached through a cast so the file compiles against a `lib` of `es2020`, where the API is not yet\n * declared, and guarded so it cannot throw where the API is absent. The API is ES2022 and\n * present in every current runtime, so the guard is belt-and-braces rather than a real scenario —\n * but a picker that throws is worse than one that is empty, and `currencies` is there either way.\n */\nfunction listSupportedCurrencies(): string[] {\n  const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf\n  try {\n    const all = supported ? supported.call(Intl, \"currency\") : []\n    return all.filter((code) => !(NON_TENDER_CURRENCY_CODES as readonly string[]).includes(code))\n  } catch {\n    return []\n  }\n}\n\nexport interface CurrencySelectProps {\n  /** Controlled ISO 4217 code, e.g. \"JPY\". Pair with `onValueChange`. */\n  value?: string\n  /** Starting code for an uncontrolled field. Ignored once `value` is passed. */\n  defaultValue?: string\n  /** Called with the chosen ISO 4217 code. Never called with a display name or a symbol. */\n  onValueChange?: (code: string) => void\n  /**\n   * Language the currency names are shown in (default: the runtime's own). Passing this explicitly\n   * is what makes a server-rendered page deterministic — see the note on `mounted` below.\n   */\n  locale?: string\n  /**\n   * The codes to offer, in place of everything the runtime knows. Pass the currencies you actually\n   * price, bill or pay out in. Codes the runtime cannot name are dropped rather than shown raw.\n   */\n  currencies?: readonly string[]\n  /**\n   * Codes pinned above the alphabet, in the order given — the two or three currencies most of your\n   * revenue is in. They stay in the main list too, so searching still finds them where a reader\n   * expects.\n   */\n  priority?: readonly string[]\n  /** Shown on the trigger while nothing is chosen. */\n  placeholder?: string\n  /** Shown in the filter box. Also its accessible name. */\n  searchPlaceholder?: string\n  /** Shown when the filter matches nothing. */\n  emptyMessage?: string\n  /** When set, a hidden input mirrors the code so it submits with a native form. */\n  name?: string\n  /**\n   * Show the currency symbol beside the code. On by default, unlike the flags in `country-select`:\n   * a symbol is ordinary text that every font covers, and for the ~20 currencies that have a\n   * distinct one it is the glyph a reader recognises before they read anything. The name and the\n   * code are always shown too, so turning it off costs nothing but the glyph.\n   */\n  symbols?: boolean\n  disabled?: boolean\n  /** Lands on the trigger, so a `<label htmlFor>` names the control. */\n  id?: string\n  className?: string\n  /** Give one of these, or an `id` paired with a visible `<label>`. */\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\n/**\n * The currency's name in `locale`, or the code itself when the runtime has no name for it.\n *\n * Exported because the name is needed outside the field too — an invoice header, a plan comparison,\n * a read-only billing row — and going through the same function keeps those spellings identical to\n * the one the user picked from.\n */\nexport function getCurrencyName(code: string, locale?: string): string {\n  try {\n    return new Intl.DisplayNames(locale, { type: \"currency\" }).of(code) ?? code\n  } catch {\n    // `Intl` throws a RangeError on anything that is not three letters, including \"\" and \"JP\".\n    return code\n  }\n}\n\n/**\n * The currency's symbol, or \"\" when it does not have one distinct from its code.\n *\n * Most currencies do not: of the ~160 the runtime knows, only about twenty format to something\n * other than their own three letters, and those twenty are the ones anyone would recognise — $ € £\n * ¥ ₹ ₩ ₪ ₱ ₫ and the handful of qualified dollars. Returning \"\" for the rest is the point; the row\n * shows the code there instead of repeating it twice.\n *\n * This uses the default `currencyDisplay`, not `narrowSymbol`, and that choice is load-bearing in a\n * picker. `narrowSymbol` renders AUD, CAD, NZD, SGD, HKD and USD all as a bare \"$\" — fine beside an\n * amount whose currency you already know, useless in a list whose whole job is telling them apart.\n * The default keeps them qualified: A$, CA$, NZ$, HK$, $.\n */\nexport function getCurrencySymbol(code: string, locale?: string): string {\n  try {\n    const symbol = new Intl.NumberFormat(locale, { style: \"currency\", currency: code })\n      .formatToParts(1)\n      .find((part) => part.type === \"currency\")?.value\n    return !symbol || symbol === code ? \"\" : symbol\n  } catch {\n    return \"\"\n  }\n}\n\n/**\n * How many decimal places this currency is written with: 2 for most, 0 for JPY, KRW, VND, ISK and\n * some thirty others, 3 for the Gulf dinars (BHD, JOD, KWD, LYD, OMR, TND).\n *\n * This is the number a hand-rolled currency field gets wrong, because 2 looks like a safe default\n * and is wrong for about a quarter of the list. It matters most at the payment boundary: Stripe,\n * Adyen and PayPal all take an amount in the currency's *minor* unit, so the conversion is\n * `Math.round(amount * 10 ** getCurrencyFractionDigits(code))` — and hardcoding 2 there bills a\n * Japanese customer a hundred times what they agreed to.\n *\n * Feed the chosen code straight to `currency-input`'s `currency` prop and the displayed field gets\n * the same treatment; this export is for the arithmetic on your own side of it.\n */\nexport function getCurrencyFractionDigits(code: string): number {\n  try {\n    return (\n      new Intl.NumberFormat(\"en\", { style: \"currency\", currency: code }).resolvedOptions()\n        .maximumFractionDigits ?? 2\n    )\n  } catch {\n    return 2\n  }\n}\n\n/**\n * Folded for searching: lower-cased, stripped of accents, and stripped of everything that is not a\n * letter or a digit.\n *\n * Currency names carry all three problems. Accents, in \"Costa Rican Colón\" and \"Nicaraguan\n * Córdoba\". Punctuation, in \"São Tomé & Príncipe Dobra\" and \"Trinidad & Tobago Dollar\", where the\n * ampersand sits between two words a person will type with a space or with \"and\". Spaces, so\n * \"swissfranc\" and \"Swiss Franc\" are the same query.\n */\nfunction fold(text: string): string {\n  return text\n    .normalize(\"NFD\")\n    .replace(/[̀-ͯ]/g, \"\")\n    .toLowerCase()\n    .replace(/[^\\p{Letter}\\p{Number}]/gu, \"\")\n}\n\ninterface CurrencyOption {\n  code: string\n  /** What the row renders, in the reader's language. */\n  name: string\n  /** The symbol, or \"\" when this currency has none distinct from its code. */\n  symbol: string\n  /** `name`, folded. */\n  search: string\n  /** The English name, folded — see `matches`. */\n  searchEnglish: string\n}\n\n/**\n * How well `option` answers `query`, or -1 for no match at all. Higher is better.\n *\n * Four haystacks, because a currency has four names a person might type. The one in front of them\n * (\"日本円\"), the English one (\"Japanese Yen\" — typed constantly on non-English sites, because it is\n * what the pricing page and the payment processor say), the code (\"JPY\", which is what the API\n * takes and therefore what a developer has in their head), and the symbol.\n *\n * The symbol has to be read before folding, because it is the one query that survives folding as\n * nothing at all: `fold(\"¥\")` is \"\". Without this line, typing a symbol would fold to an empty\n * query and quietly show the entire list, which reads as the filter being broken.\n */\nfunction matches(option: CurrencyOption, query: string, rawQuery: string): number {\n  // An exact code is unambiguous and wins outright: \"sek\" should not bury the Swedish Krona under\n  // every currency whose name happens to contain those letters.\n  if (rawQuery.length === 3 && option.code.toLowerCase() === rawQuery) return 4\n  if (option.symbol && option.symbol.toLowerCase() === rawQuery) return 3\n  // Below here every test is on the folded query, and an empty one matches everything — which is\n  // exactly wrong once the raw query was something (a symbol) rather than nothing.\n  if (!query) return -1\n  if (option.search.startsWith(query)) return 2\n  if (option.searchEnglish.startsWith(query)) return 1\n  if (option.search.includes(query) || option.searchEnglish.includes(query)) return 0\n  return -1\n}\n\nconst ChevronIcon = ({ open }: { open: boolean }) => (\n  <svg\n    className={cn(\n      \"pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-transform\",\n      open && \"rotate-180\"\n    )}\n    width=\"16\"\n    height=\"16\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"m6 9 6 6 6-6\" />\n  </svg>\n)\n\nconst CheckIcon = () => (\n  <svg\n    className=\"h-4 w-4\"\n    width=\"16\"\n    height=\"16\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M20 6 9 17l-5-5\" />\n  </svg>\n)\n\n/**\n * A currency picker: every ISO 4217 currency the runtime knows, named in the reader's language,\n * sorted the way that language sorts, and searchable by local name, English name, code or symbol.\n *\n * ```tsx\n * const [currency, setCurrency] = React.useState(\"USD\")\n *\n * return (\n *   <>\n *     <Label htmlFor=\"currency\">Currency</Label>\n *     <CurrencySelect\n *       id=\"currency\"\n *       name=\"currency\"\n *       value={currency}\n *       onValueChange={setCurrency}\n *       priority={[\"USD\", \"EUR\", \"GBP\"]}\n *     />\n *     <CurrencyInput currency={currency} value={amount} onValueChange={setAmount} />\n *   </>\n * )\n * ```\n *\n * That last pairing is the reason to reach for this rather than a `<select>` of three hardcoded\n * options. The code you get back is what `currency-input` needs to place the symbol and round to\n * the right precision, and what `Intl.NumberFormat` needs everywhere else you print a price — and\n * precision is where hand-written money code breaks, because two decimals is wrong for about a\n * quarter of the world's currencies (see `getCurrencyFractionDigits`).\n *\n * The value is always the ISO 4217 code, never a name or a symbol, so what you store stays stable\n * when the reader's language changes.\n */\nexport function CurrencySelect({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  locale,\n  currencies,\n  priority,\n  placeholder = \"Select a currency\",\n  searchPlaceholder = \"Search currencies…\",\n  emptyMessage = \"No currency found.\",\n  name,\n  symbols = true,\n  disabled = false,\n  id,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n}: CurrencySelectProps) {\n  const isControlled = valueProp !== undefined\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? \"\")\n  const value = isControlled ? valueProp : uncontrolled\n\n  const [open, setOpen] = React.useState(false)\n  const [query, setQuery] = React.useState(\"\")\n  const [active, setActive] = React.useState(0)\n\n  const generatedId = React.useId()\n  const triggerId = id ?? generatedId\n  const listboxId = `${generatedId}-listbox`\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const searchRef = React.useRef<HTMLInputElement>(null)\n\n  /**\n   * Names are resolved after mount unless the caller pinned the language, and only then.\n   *\n   * `Intl.DisplayNames(undefined)` reads the runtime's own locale, and the server's locale is the\n   * server's — a page rendered in en-US on the server and read in ja-JP hydrates with a different\n   * word in the trigger, which is the classic mismatch. Passing `locale` removes the disagreement,\n   * so that case renders the real name on the first pass and never flashes. Left to the runtime,\n   * the trigger shows the code until hydration: present, correct, submittable, just terser.\n   *\n   * It does one more thing here than it does in `country-select`. The option list is read from the\n   * runtime rather than from a table, so the server's list and the browser's could genuinely\n   * differ; gating the whole build on this keeps that difference off the server-rendered HTML,\n   * where the panel is closed and only the trigger's own label is drawn.\n   */\n  const [mounted, setMounted] = React.useState(false)\n  React.useEffect(() => {\n    setMounted(true)\n  }, [])\n  const namesReady = mounted || locale !== undefined\n\n  const options = React.useMemo<CurrencyOption[]>(() => {\n    if (!namesReady) return []\n    const english = (code: string) => getCurrencyName(code, \"en\")\n    const collator = new Intl.Collator(locale)\n    return (currencies ?? listSupportedCurrencies())\n      .map((code) => {\n        const name = getCurrencyName(code, locale)\n        return {\n          code,\n          name,\n          symbol: getCurrencySymbol(code, locale),\n          search: fold(name),\n          searchEnglish: fold(english(code)),\n        }\n      })\n      // A code the runtime cannot name comes back as the code itself. Offering it would put three\n      // raw letters in a list of currency names, so it is dropped the way timezone-select drops a\n      // zone it cannot format.\n      .filter((option) => option.name !== option.code)\n      .sort((a, b) => collator.compare(a.name, b.name))\n  }, [currencies, locale, namesReady])\n\n  const byCode = React.useMemo(() => {\n    const map = new Map<string, CurrencyOption>()\n    for (const option of options) map.set(option.code, option)\n    return map\n  }, [options])\n\n  /** The pinned rows, in the order the caller gave, skipping anything not on offer. */\n  const pinned = React.useMemo(() => {\n    if (!priority?.length) return []\n    return priority.map((code) => byCode.get(code)).filter(Boolean) as CurrencyOption[]\n  }, [priority, byCode])\n\n  /**\n   * The rows as drawn: pinned block first while the field is unfiltered, then the alphabet. Once\n   * there is a query the pinning is dropped — a search result ordered by anything other than how\n   * well it matched reads as a bug.\n   */\n  const rows = React.useMemo(() => {\n    const raw = query.trim().toLowerCase()\n    const folded = fold(query)\n    // Emptiness is decided on the raw query, not the folded one: \"¥\" folds away to nothing but is\n    // very much a search.\n    if (!raw) {\n      return {\n        pinned,\n        rest: options,\n        all: [...pinned, ...options],\n      }\n    }\n    const scored: Array<{ option: CurrencyOption; score: number }> = []\n    for (const option of options) {\n      const score = matches(option, folded, raw)\n      if (score >= 0) scored.push({ option, score })\n    }\n    // Stable within a score band: the collator already ordered `options`, and `sort` is stable, so\n    // equally good matches stay alphabetical instead of shuffling as the query grows.\n    scored.sort((a, b) => b.score - a.score)\n    const all = scored.map((s) => s.option)\n    return { pinned: [], rest: all, all }\n  }, [options, pinned, query])\n\n  const selected = value ? byCode.get(value) : undefined\n\n  /**\n   * What the trigger says. A stored code that is not on offer — a currency dropped from a narrowed\n   * `currencies`, or one saved before the list was narrowed — shows as its own name rather than\n   * falling back to the placeholder, which would read as \"nothing chosen\" and quietly lose the\n   * answer on the next save. That case is more than hypothetical here: a ledger row written in a\n   * currency you have since stopped accepting still has to render.\n   */\n  const triggerName = !value\n    ? placeholder\n    : selected\n      ? selected.name\n      : namesReady\n        ? getCurrencyName(value, locale)\n        : value\n  const triggerSymbol = !value\n    ? \"\"\n    : selected\n      ? selected.symbol\n      : namesReady\n        ? getCurrencySymbol(value, locale)\n        : \"\"\n\n  const openPanel = React.useCallback(() => {\n    if (disabled) return\n    setOpen(true)\n    setQuery(\"\")\n    setActive(0)\n  }, [disabled])\n\n  const closePanel = React.useCallback((refocus: boolean) => {\n    setOpen(false)\n    if (refocus) triggerRef.current?.focus()\n  }, [])\n\n  function choose(option: CurrencyOption) {\n    if (!isControlled) setUncontrolled(option.code)\n    onValueChange?.(option.code)\n    closePanel(true)\n  }\n\n  // Focus the filter box when the panel opens.\n  React.useEffect(() => {\n    if (!open) return\n    const timer = window.setTimeout(() => searchRef.current?.focus(), 0)\n    return () => window.clearTimeout(timer)\n  }, [open])\n\n  // Start on the chosen currency, so opening a field that already says Japanese Yen lands on it\n  // rather than on the top of the alphabet 80 rows above.\n  React.useEffect(() => {\n    if (!open || !value) return\n    const index = rows.all.findIndex((option) => option.code === value)\n    if (index >= 0) setActive(index)\n    // Only when the panel opens: re-running this as the query changes would drag the highlight\n    // back to the selected row after every keystroke.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open])\n\n  // Close on an outside pointer press (capture, so it beats focus moves).\n  React.useEffect(() => {\n    if (!open) return\n    function onPointerDown(event: PointerEvent) {\n      if (!rootRef.current?.contains(event.target as Node)) closePanel(false)\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown, true)\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true)\n  }, [open, closePanel])\n\n  // Clamp the highlight when the filter shrinks the list under it.\n  React.useEffect(() => {\n    setActive((current) => Math.min(current, Math.max(0, rows.all.length - 1)))\n  }, [rows.all.length])\n\n  // Keep the highlighted row on screen while arrowing through 160 of them. Looked up by id rather\n  // than queried off the list, because `useId` mints ids containing colons and a selector would\n  // have to be escaped before it parsed.\n  React.useEffect(() => {\n    if (!open) return\n    document.getElementById(`${generatedId}-opt-${active}`)?.scrollIntoView({ block: \"nearest\" })\n  }, [active, open, generatedId])\n\n  function handleTriggerKeyDown(event: React.KeyboardEvent) {\n    if (disabled) return\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault()\n      openPanel()\n    }\n  }\n\n  function handlePanelKeyDown(event: React.KeyboardEvent) {\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        setActive((a) => Math.max(0, Math.min(a + 1, rows.all.length - 1)))\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        setActive((a) => Math.max(a - 1, 0))\n        break\n      case \"Home\":\n        event.preventDefault()\n        setActive(0)\n        break\n      case \"End\":\n        event.preventDefault()\n        setActive(Math.max(0, rows.all.length - 1))\n        break\n      case \"Enter\": {\n        event.preventDefault()\n        const option = rows.all[active]\n        if (option) choose(option)\n        break\n      }\n      case \"Escape\":\n        event.preventDefault()\n        closePanel(true)\n        break\n      case \"Tab\":\n        closePanel(false)\n        break\n    }\n  }\n\n  let index = -1\n  const renderRow = (option: CurrencyOption) => {\n    index += 1\n    const rowIndex = index\n    const isSelected = option.code === value\n    return (\n      <li\n        key={`${option.code}-${rowIndex}`}\n        id={`${generatedId}-opt-${rowIndex}`}\n        role=\"option\"\n        aria-selected={isSelected}\n        onPointerMove={() => setActive(rowIndex)}\n        // Keep focus in the filter box so the arrow keys still work after a click.\n        onPointerDown={(event) => event.preventDefault()}\n        onClick={() => choose(option)}\n        className={cn(\n          \"flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm\",\n          rowIndex === active && \"bg-accent text-accent-foreground\"\n        )}\n      >\n        <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">\n          {isSelected ? <CheckIcon /> : null}\n        </span>\n        <span className=\"truncate\">{option.name}</span>\n        {symbols && option.symbol ? (\n          // Decoration beside a name and a code that already say which currency this is, and one\n          // that a screen reader would otherwise read out as a bare \"$\".\n          <span aria-hidden=\"true\" className=\"ml-auto shrink-0 text-xs text-muted-foreground\">\n            {option.symbol}\n          </span>\n        ) : null}\n        <span\n          className={cn(\n            \"shrink-0 font-mono text-xs text-muted-foreground\",\n            !(symbols && option.symbol) && \"ml-auto\"\n          )}\n        >\n          {option.code}\n        </span>\n      </li>\n    )\n  }\n\n  return (\n    <div ref={rootRef} className={cn(\"relative\", className)}>\n      <button\n        ref={triggerRef}\n        id={triggerId}\n        // Never \"submit\": this control lives inside forms, and the browser's default would post\n        // the form the moment someone opened the currency list.\n        type=\"button\"\n        role=\"combobox\"\n        aria-expanded={open}\n        aria-haspopup=\"listbox\"\n        aria-controls={open ? listboxId : undefined}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        disabled={disabled}\n        onClick={() => (open ? closePanel(false) : openPanel())}\n        onKeyDown={handleTriggerKeyDown}\n        className={cn(\n          \"flex h-9 w-full items-center gap-2 rounded-md border border-input bg-transparent py-1 pl-3 pr-8 text-left text-sm shadow-sm transition-colors\",\n          \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n          \"disabled:cursor-not-allowed disabled:opacity-50\",\n          !value && \"text-muted-foreground\"\n        )}\n      >\n        {symbols && triggerSymbol ? (\n          <span aria-hidden=\"true\" className=\"shrink-0 text-muted-foreground\">\n            {triggerSymbol}\n          </span>\n        ) : null}\n        <span className=\"truncate\">{triggerName}</span>\n        {value ? (\n          <span className=\"ml-auto shrink-0 font-mono text-xs text-muted-foreground\">{value}</span>\n        ) : null}\n        <ChevronIcon open={open} />\n      </button>\n\n      {open ? (\n        <div\n          onKeyDown={handlePanelKeyDown}\n          className=\"absolute z-50 mt-1 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md\"\n        >\n          <input\n            ref={searchRef}\n            type=\"text\"\n            role=\"searchbox\"\n            autoComplete=\"off\"\n            value={query}\n            onChange={(event) => {\n              setQuery(event.target.value)\n              setActive(0)\n            }}\n            placeholder={searchPlaceholder}\n            aria-label={searchPlaceholder}\n            aria-controls={listboxId}\n            aria-activedescendant={\n              rows.all.length > 0 ? `${generatedId}-opt-${active}` : undefined\n            }\n            className=\"w-full border-b bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground\"\n          />\n          <ul\n            id={listboxId}\n            role=\"listbox\"\n            aria-label={ariaLabel ?? placeholder}\n            tabIndex={-1}\n            className=\"max-h-60 overflow-y-auto p-1 focus-visible:outline-none\"\n          >\n            {rows.all.length === 0 ? (\n              // Not an option, so it stays out of the listbox's owned children.\n              <li role=\"presentation\" className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n                {emptyMessage}\n              </li>\n            ) : null}\n            {rows.pinned.length > 0 ? (\n              <>\n                {rows.pinned.map(renderRow)}\n                <li role=\"presentation\" className=\"my-1 border-t\" />\n              </>\n            ) : null}\n            {rows.rest.map(renderRow)}\n          </ul>\n        </div>\n      ) : null}\n\n      {name ? <input type=\"hidden\" name={name} value={value} /> : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}