{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "language-select",
  "title": "Language Select",
  "description": "A UI language picker: every language named in its own language — 日本語, Deutsch, العربية, 한국어 — with its name in the page's language beside it, its BCP-47 tag, and a filter that reaches it by all four. Reach for it wherever a form has to settle which language something is read in: the interface or display language in account, profile, workspace and organisation settings; the language switcher on a multilingual site, marketing page or docs site; the language a customer's emails, notifications, invoices and receipts are sent in; the locale on an admin's view of a user, a seat or a tenant; the language of a help centre, knowledge base or support ticket; subtitle and audio-track selection; the source and target language on a translation or localisation screen; and the \"preferred language\" row on onboarding and sign-up. Common asks it answers: \"language select\", \"language picker\", \"language switcher react\", \"locale select\", \"locale picker\", \"i18n language dropdown\", \"language selector shadcn\", \"shadcn language switcher\", \"BCP-47 select\", \"language combobox\", \"next-intl language switcher\", \"i18next language selector\", \"react-i18next dropdown\", \"choose interface language\", \"native language names select\", \"endonym language list\", \"list of languages react\", \"RTL language select\". Official shadcn/ui has no language, locale or i18n item of any kind, and touches Intl nowhere in its sixty-three components — and the one item whose name comes close, direction, is a Radix DirectionProvider wrapper that takes a dir you have already worked out; it has no idea which languages are right-to-left. This fills that gap and exports the missing line between them. The fact the whole API is shaped around: there is no list of languages to read. Intl.supportedValuesOf answers for calendars, collations, currencies, numbering systems, time zones and units, and throws a RangeError for \"language\", \"locale\", \"region\" and \"script\" — unlike pulld's country-select and currency-select, which read the world's list at runtime. That absence is the right shape anyway, because a UI language field offers what you have actually translated, not the world's eight thousand languages: it offers the directories in your locales/ folder. So `languages` is the main prop — `languages={Object.keys(messages)}` — with a documented default of forty-one common tags so it renders something sensible on install. Every row leads with the endonym, the language naming itself, from Intl.DisplayNames(tag).of(tag). That is the one thing a hand-written language picker gets wrong, and it is not a nicety: the person opening this control is very often someone who cannot read the page it is on — that is why they opened it — so a list that says \"Japanese\" is no help to them and a list that says 日本語 is. The name in the page's language sits beside it for whoever is choosing on someone else's behalf, support staff setting a customer's locale or an admin filling in a seat, and disappears when it would only repeat the endonym. Tags, not bare ISO 639 codes, because a tag is what a locale directory is called: pt-BR and pt-PT are two shipped translations and the runtime names both (\"Brazilian Portuguese\", \"European Portuguese\"), zh-Hans is \"Simplified Chinese\", es-419 is \"Latin American Spanish\", and in Japanese the same call gives「ポルトガル語 (ブラジル)」. Right-to-left is line-drawn on purpose. Each row is marked with lang and dir, so a screen reader pronounces 한국어 with a Korean voice instead of spelling it out and العربية is laid out correctly inside a list that is not — but the component does not touch your document, because turning a chosen language into an RTL page is a decision about the whole tree. getLanguageDirection(tag) is exported so the two lines that do are yours (`document.documentElement.lang = tag; document.documentElement.dir = getLanguageDirection(tag)`), it is what you feed shadcn's own direction item, and it knows more than Arabic and Hebrew: Persian, Urdu, Pashto, Sorani Kurdish, Yiddish, Divehi, Sindhi and Uyghur are right-to-left too, while Kurmanji Kurdish and Azerbaijani are not. A tag the runtime cannot name is dropped rather than drawn raw — and the rule its sibling pickers use is not enough here, because Intl names \"xx-US\" as \"xx (United States)\", so the language subtag is checked as well; \"pt_BR\", the POSIX spelling that Intl rejects outright, and tags carrying a -u- extension go the same way. The filter reads four faces, because four different people type into it: the endonym (someone looking for their own language), the name in the page's language (someone choosing for them), the English name (typed constantly on non-English sites, because it is what the documentation says), and the tag itself (the developer, who has \"pt-BR\" in their head because it is the name of a directory). An exact tag wins outright, so \"id\" answers with Indonesian rather than Ido; a partial one finds a family, so \"pt\" brings both Portuguese translations and \"zh\" both Chinese scripts; folding strips accents and punctuation, so \"cestina\" finds čeština and 日本 finds 日本語; and a query that folds away to nothing, like \"()\", filters instead of quietly showing every row. Sorted through Intl.Collator by what the rows actually say, which groups the list by script and lets the reader's own collation decide where their script lands. The trigger is labelled with the endonym, and because that label does not depend on who is reading, it renders correctly on the server and never flashes a bare tag through hydration the way a name in the reader's language has to. 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 日本語 starts on 日本語, and an outside press closes it. Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`), and always emits the caller's own tag, character for character — never canonicalised, because that string is a key into your translations. `name` adds a hidden input so it submits with a native form; a stored tag outside a narrowed `languages` is still named rather than reading as \"nothing chosen\"; `priority` pins the two or three languages most of your readers use above the rest; `tags` turns off the tag column; and getLanguageName(), getLanguageEndonym() and getLanguageDirection() are exported for the rest of the page. Styled entirely with shadcn tokens (input, ring, accent, popover, muted-foreground), so it follows light and dark mode, and it ships zero dependencies — no locale-data package, no icon package, one file.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/language-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * A starting set of UI languages — the tags a product's `locales/` folder usually holds.\n *\n * This list exists because languages are the one thing `Intl` will not enumerate for you.\n * `Intl.supportedValuesOf` answers for calendars, collations, currencies, numbering systems, time\n * zones and units; ask it for `\"language\"`, `\"locale\"`, `\"region\"` or `\"script\"` and it throws a\n * RangeError. There is no browser-supplied list to read.\n *\n * That absence is the right shape for this control anyway, and it is what separates this from\n * `country-select` and `currency-select`. A currency field offers every currency in the world\n * because a customer may be paid in any of them. A *UI language* field offers what you have\n * actually translated — which is not the world's ~8,000 languages, it is the seven directories on\n * disk, and you already know their names. So pass `languages`. This default is a demo that renders\n * something sensible on install, not a claim about which languages matter.\n *\n * These are BCP-47 tags, not bare ISO 639 codes, because that is what a locale directory is named\n * and what `Accept-Language` carries: `pt-BR` and `pt-PT` are two shipped translations, and the\n * runtime names both (\"Brazilian Portuguese\", \"European Portuguese\"). Use hyphens — `pt_BR`, the\n * POSIX and Java spelling, is not a valid tag and is dropped rather than shown raw.\n */\nexport const DEFAULT_LANGUAGE_TAGS = [\n  \"ar\", \"bg\", \"bn\", \"ca\", \"cs\", \"da\", \"de\", \"el\", \"en\", \"en-GB\", \"es\", \"es-419\", \"fa\", \"fi\", \"fr\",\n  \"fr-CA\", \"he\", \"hi\", \"hr\", \"hu\", \"id\", \"it\", \"ja\", \"ko\", \"ms\", \"nb\", \"nl\", \"pl\", \"pt-BR\", \"pt-PT\",\n  \"ro\", \"ru\", \"sk\", \"sr\", \"sv\", \"th\", \"tr\", \"uk\", \"vi\", \"zh-Hans\", \"zh-Hant\",\n] as const\n\n/**\n * Scripts written right to left, used only where the runtime will not answer the question itself.\n *\n * Not a list of RTL *languages* — that list is long, it changes, and stopping it at Arabic and\n * Hebrew is precisely what hand-rolled i18n gets wrong. A script is the stable thing, and the\n * runtime will tell you which one a language is written in even when it will not tell you which\n * way that script runs.\n *\n * The ten here are not copied from a Unicode table: every living language `Intl` itself calls\n * right-to-left was asked what script it resolves to, and this is the set that came back. Arabic\n * carries most of them (Persian, Urdu, Pashto, Sorani Kurdish, Sindhi, Uyghur, Kashmiri and twenty\n * more), Hebrew carries Hebrew and Yiddish, and the rest are one language each. Historical scripts\n * are left out on purpose: no product ships a Phoenician translation, and a table nobody can reach\n * is a table nobody maintains.\n */\nconst RTL_SCRIPTS = new Set([\n  \"Adlm\", \"Arab\", \"Armi\", \"Hebr\", \"Mand\", \"Nkoo\", \"Rohg\", \"Samr\", \"Syrc\", \"Thaa\",\n])\n\n/**\n * What `tag` is called in `locale`, or the tag itself when the runtime has no name for it.\n *\n * The runtime names the whole tag, not just its first two letters: `pt-BR` comes back as \"Brazilian\n * Portuguese\", `zh-Hans` as \"Simplified Chinese\", `es-419` as \"Latin American Spanish\", and in\n * Japanese the same call gives「ポルトガル語 (ブラジル)」. That is the default `languageDisplay` of\n * `\"dialect\"`; the alternative, `\"standard\"`, would say \"Portuguese (Brazil)\". Dialect is the right\n * one here because it matches how the row will be read aloud and how the rest of the product refers\n * to that translation.\n */\nexport function getLanguageName(tag: string, locale?: string): string {\n  try {\n    return new Intl.DisplayNames(locale, { type: \"language\" }).of(tag) ?? tag\n  } catch {\n    // `Intl` throws a RangeError on anything that is not a structurally valid tag — \"\", \"x\",\n    // \"pt_BR\" with an underscore, and a tag carrying a `-u-` extension.\n    return tag\n  }\n}\n\n/**\n * The language's name **in itself**: 日本語, Deutsch, العربية, 한국어.\n *\n * This is the one line that makes a language picker work, and the one a hand-written picker gets\n * wrong, because every other picker in a product is drawn in the reader's language and this one\n * cannot be. The person reaching for it is, very often, someone who cannot read the language the\n * page is currently in — that is *why* they are opening it. A list that says \"Japanese\" is useless\n * to them; a list that says 日本語 is not.\n *\n * The trick is only that the display locale and the subject are the same tag.\n */\nexport function getLanguageEndonym(tag: string): string {\n  return getLanguageName(tag, tag)\n}\n\n/**\n * Which way `tag` is written: \"rtl\" for Arabic, Hebrew, Persian, Urdu, Pashto, Sorani Kurdish,\n * Yiddish, Divehi, Sindhi and Uyghur; \"ltr\" for everything else, including anything unrecognised.\n *\n * Exported because **this component does not set `dir` on your page and cannot**. It returns a\n * language tag; turning that into a right-to-left document is a decision about the whole tree —\n * `<html dir>`, your layout, your icons, your charts — and it belongs to the code that swaps the\n * translation, not to a `<select>`. Without this line people report \"I picked Arabic and nothing\n * flipped\", so the line ships next to the picker:\n *\n * ```tsx\n * document.documentElement.lang = tag\n * document.documentElement.dir = getLanguageDirection(tag)\n * ```\n *\n * If you use shadcn/ui's own `direction` item, this is what you feed it: `DirectionProvider` takes\n * a `dir` and hands it to the Radix primitives, but it has no idea which languages are RTL. That\n * gap is exactly the width of this function.\n *\n * Asked of the runtime first (`Intl.Locale.prototype.getTextInfo`, and the earlier `textInfo`\n * accessor where that is what exists), and answered from the language's own script when neither is\n * there. The fallback is not a guess: `new Intl.Locale(\"ckb\").maximize()` resolves to\n * `ckb-Arab-IQ`, and Arab is a right-to-left script. Both paths were checked against each other\n * across the RTL languages named above and agreed on every one.\n */\nexport function getLanguageDirection(tag: string): \"ltr\" | \"rtl\" {\n  try {\n    const locale = new Intl.Locale(tag) as Intl.Locale & {\n      getTextInfo?: () => { direction?: string }\n      textInfo?: { direction?: string }\n    }\n    const direction = locale.getTextInfo?.().direction ?? locale.textInfo?.direction\n    if (direction === \"rtl\" || direction === \"ltr\") return direction\n    return RTL_SCRIPTS.has(locale.maximize().script ?? \"\") ? \"rtl\" : \"ltr\"\n  } catch {\n    return \"ltr\"\n  }\n}\n\n/** The tag's language subtag: \"pt\" from \"pt-BR\", \"zh\" from \"cmn-Hans-CN\". */\nfunction languageSubtag(tag: string): string {\n  try {\n    return new Intl.Locale(tag).language\n  } catch {\n    return tag.split(\"-\")[0]\n  }\n}\n\n/**\n * Whether the runtime knows what this tag is, and therefore whether it can be offered.\n *\n * Two questions, not one, and the second is the one worth writing down. `country-select` and\n * `currency-select` both decide this with `name !== code`: a code the runtime cannot name comes\n * back as itself, so the comparison catches it. Here that test **passes an unknown language\n * through** the moment it carries a region — `Intl` names \"xx-US\" as \"xx (United States)\", which is\n * not equal to \"xx-US\" and would be drawn as a row. So the language subtag is checked too: \"xx\" on\n * its own is named \"xx\", the tag is unknown, and it is dropped.\n *\n * Checked in the reader's language rather than in English, so the rule is \"a name this reader can\n * read\" and not \"a name someone somewhere could read\".\n */\nfunction isNameable(tag: string, locale: string | undefined): boolean {\n  if (getLanguageEndonym(tag) === tag) return false\n  const subtag = languageSubtag(tag)\n  return getLanguageName(subtag, locale) !== subtag\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 * Endonyms need all three. Accents, in \"čeština\", \"español\" and \"Türkçe\" — a Czech speaker on a\n * US keyboard types \"cestina\". Punctuation and spaces, in \"português (Brasil)\" and \"norsk bokmål\",\n * where the parenthesis sits between the two words someone would type together. Non-Latin endonyms\n * pass through unchanged, which is what makes typing 日本 find 日本語.\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 LanguageOption {\n  /** The BCP-47 tag, exactly as the caller wrote it — this is the value that leaves the control. */\n  tag: string\n  /** The language in itself: 日本語. What the row leads with. */\n  endonym: string\n  /** The language in the reader's language: \"Japanese\". \"\" when it is the same word. */\n  translated: string\n  /** \"rtl\" for Arabic, Hebrew and the rest — used to draw the endonym, not to change your page. */\n  direction: \"ltr\" | \"rtl\"\n  /** `endonym`, folded. */\n  search: string\n  /** `translated`, folded. */\n  searchTranslated: string\n  /** The English name, folded — see `matches`. */\n  searchEnglish: string\n  /** The tag, folded: \"ptbr\". */\n  searchTag: string\n}\n\n/**\n * How well `option` answers `query`, or -1 for no match at all. Higher is better.\n *\n * Four things a person might type, and they are typed by different people. The endonym, by someone\n * looking for their own language. The name in the page's language, by someone choosing on another\n * person's behalf — support staff setting a customer's locale, an admin filling in a seat. The\n * English name, on every non-English site, because English is what the documentation and the\n * `Accept-Language` header say. And the tag itself, by the developer wiring this up, who has \"pt-BR\"\n * in their head because it is the name of a directory.\n *\n * The tag is worth two bands. An exact one wins outright — typing \"da\" should put dansk on top and\n * not bury it under every language whose name contains those letters — and a prefix is what finds\n * both halves of a pair: \"pt\" brings pt-BR and pt-PT, \"zh\" brings both Chinese scripts.\n */\nfunction matches(option: LanguageOption, query: string, rawQuery: string): number {\n  if (option.tag.toLowerCase() === rawQuery) return 5\n  // Everything below is tested against the folded query, and an empty one matches everything —\n  // which is wrong once the raw query was something rather than nothing. Folding strips\n  // punctuation, so a query of \"()\" folds to \"\" while the field plainly has a query in it, and\n  // every test below would say yes. The guard has to sit above the first of them, the tag, and not\n  // just above the ones on names.\n  if (!query) return -1\n  if (option.searchTag.startsWith(query)) return 4\n  if (option.search.startsWith(query)) return 3\n  // The other two names share a band. Whether \"the reader's word for it starts with this\" should\n  // outrank \"the English word for it starts with this\" has no answer worth encoding — the two\n  // readers who type them are different people — and splitting them would be a rule nothing could\n  // demonstrate. What does matter is that a name *starting* with the query beats one that merely\n  // contains it: typing \"man\" should find Manx before it finds German and Romanian.\n  if (option.searchTranslated.startsWith(query) || option.searchEnglish.startsWith(query)) return 2\n  if (\n    option.search.includes(query) ||\n    option.searchTranslated.includes(query) ||\n    option.searchEnglish.includes(query) ||\n    option.searchTag.includes(query)\n  )\n    return 1\n  return -1\n}\n\nexport interface LanguageSelectProps {\n  /** Controlled BCP-47 tag, e.g. \"pt-BR\". Pair with `onValueChange`. */\n  value?: string\n  /** Starting tag for an uncontrolled field. Ignored once `value` is passed. */\n  defaultValue?: string\n  /** Called with the chosen tag, exactly as it appears in `languages` — never with a name. */\n  onValueChange?: (tag: string) => void\n  /**\n   * The languages to offer: the translations you actually ship. Defaults to\n   * `DEFAULT_LANGUAGE_TAGS`, which is a demo — the whole point of this prop is that your list is\n   * `Object.keys(messages)`, not a table someone curated. Tags the runtime cannot name are dropped\n   * rather than shown raw.\n   */\n  languages?: readonly string[]\n  /**\n   * Language the *secondary* names are shown in (default: the runtime's own). It never affects the\n   * endonym, which is always the language naming itself.\n   */\n  locale?: string\n  /**\n   * Tags pinned above the rest, in the order given — the two or three languages most of your\n   * readers use, or the one you detected from `Accept-Language`. They stay in the main list too, so\n   * searching still finds them where a reader 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 tag so it submits with a native form. */\n  name?: string\n  /**\n   * Show the BCP-47 tag beside each language. On by default: it is the string the developer wiring\n   * the form is looking for, it is what tells pt-BR from pt-PT at a glance, and it is the only part\n   * of the row that is the same in every language.\n   */\n  tags?: 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\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 UI language picker: every language named in itself, tagged, and searchable by its own name, its\n * name in the page's language, its English name or its BCP-47 tag.\n *\n * ```tsx\n * const [language, setLanguage] = React.useState(\"en\")\n *\n * return (\n *   <>\n *     <Label htmlFor=\"language\">Language</Label>\n *     <LanguageSelect\n *       id=\"language\"\n *       name=\"language\"\n *       languages={Object.keys(messages)}\n *       value={language}\n *       onValueChange={(tag) => {\n *         setLanguage(tag)\n *         document.documentElement.lang = tag\n *         document.documentElement.dir = getLanguageDirection(tag)\n *       }}\n *     />\n *   </>\n * )\n * ```\n *\n * Three things in that snippet are the reason to reach for this rather than a `<select>` of\n * hardcoded `<option>`s. The list is your translations, because there is no such thing as a\n * complete list of UI languages — `Intl` will not even enumerate them. Each row says 日本語 rather\n * than \"Japanese\", because someone who cannot read the current page has to be able to find their\n * way out of it. And `getLanguageDirection` is exported alongside, because the picker deliberately\n * does not touch your document: it hands you a tag, and the two lines that use it are yours.\n *\n * Each row is marked with `lang` and `dir`, so a screen reader pronounces 한국어 with a Korean voice\n * instead of spelling it out in English, and العربية is laid out right to left inside a list that\n * is not.\n */\nexport function LanguageSelect({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  languages,\n  locale,\n  priority,\n  placeholder = \"Select a language\",\n  searchPlaceholder = \"Search languages…\",\n  emptyMessage = \"No language found.\",\n  name,\n  tags = true,\n  disabled = false,\n  id,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n}: LanguageSelectProps) {\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   * No `mounted` gate here, and that is worth a note because both sibling pickers need one.\n   *\n   * `country-select` and `currency-select` label their trigger with a name in the reader's\n   * language, which they cannot know on a server — so they render the bare code until hydration to\n   * avoid a mismatch. This trigger is labelled with the endonym, and `DisplayNames(\"ja\").of(\"ja\")`\n   * is 日本語 wherever it runs. The label does not depend on who is reading, so the server can draw\n   * the real one and there is nothing to flash. Only the secondary names and the collation depend\n   * on the runtime's locale, and both live inside a panel that is closed until someone opens it.\n   */\n  const options = React.useMemo<LanguageOption[]>(() => {\n    const collator = new Intl.Collator(locale)\n    const seen = new Set<string>()\n    return (languages ?? DEFAULT_LANGUAGE_TAGS)\n      .filter((tag) => {\n        if (seen.has(tag) || !isNameable(tag, locale)) return false\n        seen.add(tag)\n        return true\n      })\n      .map((tag) => {\n        const endonym = getLanguageEndonym(tag)\n        const reader = getLanguageName(tag, locale)\n        const english = getLanguageName(tag, \"en\")\n        return {\n          tag,\n          endonym,\n          // Dropped when it would repeat the endonym, which is the common case for the reader's own\n          // language and for every row when the page is in English and the list is English names.\n          translated: reader === endonym ? \"\" : reader,\n          direction: getLanguageDirection(tag),\n          search: fold(endonym),\n          searchTranslated: reader === endonym ? \"\" : fold(reader),\n          searchEnglish: fold(english),\n          searchTag: fold(tag),\n        }\n      })\n      // Sorted by what the row actually says. Collating endonyms groups the list by script — the\n      // Latin-script languages in one alphabetical run, then Greek, Cyrillic, Hebrew, Arabic, the\n      // Indic scripts and CJK — and the reader's own collator decides where their script lands: in\n      // an Arabic page العربية sorts to the top, in a Japanese one 日本語 follows the Latin block.\n      // Sorting by the *translated* name instead would order the list by words most of these\n      // readers cannot read.\n      .sort((a, b) => collator.compare(a.endonym, b.endonym))\n  }, [languages, locale])\n\n  const byTag = React.useMemo(() => {\n    const map = new Map<string, LanguageOption>()\n    for (const option of options) map.set(option.tag, 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((tag) => byTag.get(tag)).filter(Boolean) as LanguageOption[]\n  }, [priority, byTag])\n\n  /**\n   * The rows as drawn: pinned block first while the field is unfiltered, then the rest. Once there\n   * is a query the pinning is dropped — a search result ordered by anything other than how well it\n   * matched reads as a bug.\n   */\n  const rows = React.useMemo(() => {\n    const raw = query.trim().toLowerCase()\n    const folded = fold(query)\n    if (!raw) {\n      return { pinned, rest: options, all: [...pinned, ...options] }\n    }\n    const scored: Array<{ option: LanguageOption; 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 in list order 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 ? byTag.get(value) : undefined\n\n  /**\n   * What the trigger says. A stored tag that is not on offer — a language you have since stopped\n   * shipping, or one saved before `languages` was narrowed — is still named rather than falling\n   * back to the placeholder, which would read as \"nothing chosen\" and quietly lose the answer on\n   * the next save.\n   */\n  const triggerLabel = !value ? placeholder : (selected?.endonym ?? getLanguageEndonym(value))\n  const triggerDirection = selected?.direction ?? (value ? getLanguageDirection(value) : \"ltr\")\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: LanguageOption) {\n    if (!isControlled) setUncontrolled(option.tag)\n    onValueChange?.(option.tag)\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 language, so opening a field that already says 日本語 lands on it rather\n  // than at the top of the list.\n  React.useEffect(() => {\n    if (!open || !value) return\n    const index = rows.all.findIndex((option) => option.tag === 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. Looked up by id rather than queried off the\n  // list, because `useId` mints ids containing colons and a selector would have to be escaped\n  // 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: LanguageOption) => {\n    index += 1\n    const rowIndex = index\n    const isSelected = option.tag === value\n    return (\n      <li\n        key={`${option.tag}-${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=\"flex min-w-0 flex-1 items-baseline gap-2\">\n          {/* `lang` so a screen reader switches voice for this word rather than reading 한국어 as\n              three unknown characters; `dir` so an RTL endonym is laid out correctly inside a list\n              that is not. Neither touches the page around the control. */}\n          <span lang={option.tag} dir={option.direction} className=\"truncate\">\n            {option.endonym}\n          </span>\n          {option.translated ? (\n            <span className=\"truncate text-xs text-muted-foreground\">{option.translated}</span>\n          ) : null}\n        </span>\n        {tags ? (\n          <span className=\"shrink-0 font-mono text-xs text-muted-foreground\">{option.tag}</span>\n        ) : null}\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 the\n        // form the moment someone opened the language 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        <span\n          lang={value ? value : undefined}\n          dir={value ? triggerDirection : undefined}\n          className=\"truncate\"\n        >\n          {triggerLabel}\n        </span>\n        {value && tags ? (\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={rows.all.length > 0 ? `${generatedId}-opt-${active}` : undefined}\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\n                role=\"presentation\"\n                className=\"px-2 py-4 text-center text-sm text-muted-foreground\"\n              >\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"
}