{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "locale-select",
  "type": "registry:ui",
  "title": "Locale Select",
  "description": "Searchable language and region picker that previews dates and numbers before you choose.",
  "categories": [
    "settings"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/locale-select.tsx",
      "content": "\"use client\";\nimport { Combobox } from \"@base-ui/react/combobox\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useMemo, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronsUpDown, Globe, Search } from \"@/lib/icons\";\nimport { ease, spring, stagger } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type LocaleOption = { code: string; currency: string };\n\n/** The languages and regions most products ship, each with the currency its region uses. */\nexport const COMMON_LOCALES: LocaleOption[] = [\n  { code: \"en-US\", currency: \"USD\" },\n  { code: \"en-GB\", currency: \"GBP\" },\n  { code: \"en-IN\", currency: \"INR\" },\n  { code: \"es-ES\", currency: \"EUR\" },\n  { code: \"es-MX\", currency: \"MXN\" },\n  { code: \"fr-FR\", currency: \"EUR\" },\n  { code: \"fr-CA\", currency: \"CAD\" },\n  { code: \"de-DE\", currency: \"EUR\" },\n  { code: \"de-CH\", currency: \"CHF\" },\n  { code: \"it-IT\", currency: \"EUR\" },\n  { code: \"pt-BR\", currency: \"BRL\" },\n  { code: \"pt-PT\", currency: \"EUR\" },\n  { code: \"nl-NL\", currency: \"EUR\" },\n  { code: \"sv-SE\", currency: \"SEK\" },\n  { code: \"nb-NO\", currency: \"NOK\" },\n  { code: \"da-DK\", currency: \"DKK\" },\n  { code: \"fi-FI\", currency: \"EUR\" },\n  { code: \"pl-PL\", currency: \"PLN\" },\n  { code: \"cs-CZ\", currency: \"CZK\" },\n  { code: \"tr-TR\", currency: \"TRY\" },\n  { code: \"uk-UA\", currency: \"UAH\" },\n  { code: \"el-GR\", currency: \"EUR\" },\n  { code: \"he-IL\", currency: \"ILS\" },\n  { code: \"ar-EG\", currency: \"EGP\" },\n  { code: \"hi-IN\", currency: \"INR\" },\n  { code: \"th-TH\", currency: \"THB\" },\n  { code: \"id-ID\", currency: \"IDR\" },\n  { code: \"vi-VN\", currency: \"VND\" },\n  { code: \"ja-JP\", currency: \"JPY\" },\n  { code: \"ko-KR\", currency: \"KRW\" },\n  { code: \"zh-CN\", currency: \"CNY\" },\n  { code: \"zh-TW\", currency: \"TWD\" },\n];\n\ntype Item = LocaleOption & { native: string; english: string; haystack: string };\ntype Group = { value: string; label: string; items: Item[] };\n\nconst fold = (s: string) => s.normalize(\"NFD\").replace(/\\p{Diacritic}/gu, \"\").toLowerCase();\n\nfunction upperFirst(s: string, locale: string) {\n  return s ? s.charAt(0).toLocaleUpperCase(locale) + s.slice(1) : s;\n}\n\n/** A locale's name in its own language and in the interface's, plus what search should match. */\nexport function describeLocale(option: LocaleOption, displayLocale = \"en\"): Item {\n  let native = option.code;\n  let english = option.code;\n  try {\n    native = upperFirst(new Intl.DisplayNames([option.code], { type: \"language\", languageDisplay: \"standard\" }).of(option.code) ?? option.code, option.code);\n    english = new Intl.DisplayNames([displayLocale], { type: \"language\", languageDisplay: \"standard\" }).of(option.code) ?? option.code;\n  } catch {}\n  return { ...option, native, english, haystack: fold(`${native} ${english} ${option.code}`) };\n}\n\n// The browser's preferred languages, following changes. Null on the server.\nconst subscribeLanguages = (fn: () => void) => {\n  window.addEventListener(\"languagechange\", fn);\n  return () => window.removeEventListener(\"languagechange\", fn);\n};\nfunction useDeviceLanguages() {\n  const joined = useSyncExternalStore(\n    subscribeLanguages,\n    () => (navigator.languages?.length ? navigator.languages : [navigator.language]).join(\",\"),\n    () => null,\n  );\n  return useMemo(() => (joined ? joined.split(\",\") : []), [joined]);\n}\n\n/** The best match in `options` for the device: an exact locale first, then the same language. */\nfunction matchDevice(options: LocaleOption[], preferred: string[]) {\n  for (const p of preferred) {\n    const exact = options.find((o) => o.code.toLowerCase() === p.toLowerCase());\n    if (exact) return exact.code;\n  }\n  for (const p of preferred) {\n    const lang = p.split(\"-\")[0].toLowerCase();\n    const same = options.find((o) => o.code.split(\"-\")[0].toLowerCase() === lang);\n    if (same) return same.code;\n  }\n  return null;\n}\n\n// A fixed moment, formatted in UTC, so server and client agree on every sample.\nconst SAMPLE = new Date(Date.UTC(2026, 8, 22, 14, 30));\n\n/** The four formats people actually notice when they change locale. */\nexport function formatSamples(option: LocaleOption, date: Date = SAMPLE) {\n  const f = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(option.code, { timeZone: \"UTC\", ...o }).format(date);\n  return {\n    date: f({ dateStyle: \"long\" }),\n    short: f({ dateStyle: \"short\" }),\n    time: f({ timeStyle: \"short\" }),\n    number: new Intl.NumberFormat(option.code).format(1234567.89),\n    currency: new Intl.NumberFormat(option.code, { style: \"currency\", currency: option.currency }).format(1299.5),\n  };\n}\n\nexport type LocaleSelectProps = {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (code: string) => void;\n  /** Which locales to offer. Defaults to COMMON_LOCALES. */\n  locales?: LocaleOption[];\n  /** The language the English-side names are written in: your interface's language. */\n  displayLocale?: string;\n  /** Hides the live format preview at the bottom of the list. */\n  hidePreview?: boolean;\n  disabled?: boolean;\n  name?: string;\n  className?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  /** Portal target for the list. */\n  container?: Combobox.Portal.Props[\"container\"];\n};\n\n/**\n * Language and region, searchable in any language it lists. Names appear in\n * their own language with yours beside them, the current and device locales\n * come first, and a footer previews dates and numbers for whichever row is\n * highlighted, before anything is committed.\n */\nexport function LocaleSelect({\n  value,\n  defaultValue,\n  onValueChange,\n  locales = COMMON_LOCALES,\n  displayLocale = \"en\",\n  hidePreview = false,\n  disabled = false,\n  name,\n  className,\n  \"aria-label\": ariaLabel = \"Language and region\",\n  \"aria-labelledby\": labelledBy,\n  container,\n}: LocaleSelectProps) {\n  const reduce = !!useReducedMotion();\n  const device = useDeviceLanguages();\n  const deviceCode = useMemo(() => matchDevice(locales, device), [locales, device]);\n  const [code, setCode] = useControllableState<string | undefined>({ value, defaultValue, onChange: (c) => c && onValueChange?.(c) });\n  const current = code ?? deviceCode ?? locales[0]?.code;\n  const [query, setQuery] = useState(\"\");\n  const [open, setOpen] = useState(false);\n  const [highlighted, setHighlighted] = useState<Item | null>(null);\n\n  const items = useMemo(() => locales.map((l) => describeLocale(l, displayLocale)), [locales, displayLocale]);\n  const selected = items.find((i) => i.code === current) ?? null;\n\n  const groups = useMemo<Group[]>(() => {\n    const q = fold(query.trim());\n    if (q) {\n      // Prefix matches on a word beat matches in the middle.\n      const starts = (i: Item) => (` ${i.haystack}`.includes(` ${q}`) ? 0 : 1);\n      const hits = items.filter((i) => i.haystack.includes(q)).sort((a, b) => starts(a) - starts(b));\n      return hits.length ? [{ value: \"results\", label: `${hits.length} ${hits.length === 1 ? \"match\" : \"matches\"}`, items: hits }] : [];\n    }\n    const top = [current, deviceCode].filter((c, i, a): c is string => !!c && a.indexOf(c) === i);\n    const suggested = top.map((c) => items.find((i) => i.code === c)).filter((i): i is Item => !!i);\n    const rest = items.filter((i) => !top.includes(i.code)).sort((a, b) => a.native.localeCompare(b.native, displayLocale));\n    return [\n      ...(suggested.length ? [{ value: \"suggested\", label: \"Suggested\", items: suggested }] : []),\n      { value: \"all\", label: \"All languages\", items: rest },\n    ];\n  }, [items, query, current, deviceCode, displayLocale]);\n\n  const preview = highlighted ?? selected;\n  const samples = preview ? formatSamples(preview) : null;\n  const { setList, y, height, opacity } = useGlide(reduce);\n\n  return (\n    <Combobox.Root<Item>\n      items={groups}\n      filteredItems={groups}\n      filter={null}\n      value={selected}\n      onValueChange={(item) => item && setCode(item.code)}\n      inputValue={query}\n      onInputValueChange={setQuery}\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (!next) {\n          setQuery(\"\");\n          setHighlighted(null);\n        }\n      }}\n      onItemHighlighted={(item) => setHighlighted(item ?? null)}\n      autoHighlight\n      isItemEqualToValue={(a, b) => a.code === b.code}\n      itemToStringLabel={(i) => i.native}\n      itemToStringValue={(i) => i.code}\n      disabled={disabled}\n      name={name}\n    >\n      <Combobox.Trigger\n        aria-label={labelledBy ? undefined : ariaLabel}\n        aria-labelledby={labelledBy}\n        className={cn(\n          \"group/trigger relative flex h-8 w-full min-w-0 max-w-[320px] items-center gap-2 rounded-lg border border-line-2 bg-raised pr-2 pl-2.5 text-left text-[13px] text-fg shadow-[var(--shadow)] select-none\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,scale] duration-150 ease-out hover:border-fg-4 hover:bg-hover active:scale-[0.985] active:duration-75\",\n          \"data-popup-open:border-fg-4 data-popup-open:bg-hover data-disabled:pointer-events-none data-disabled:opacity-50\",\n          \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:content-['']\",\n          className,\n        )}\n      >\n        <Globe size={16} className=\"shrink-0 text-fg-3\" />\n        <span className=\"grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)] overflow-hidden py-1\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={selected?.code ?? \"none\"}\n              className=\"col-start-1 row-start-1 flex min-w-0 items-baseline gap-2\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(2px)\" }}\n              transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}\n            >\n              {selected ? (\n                <>\n                  <span lang={selected.code} dir=\"auto\" className=\"truncate\" suppressHydrationWarning>\n                    {selected.native}\n                  </span>\n                  <span className=\"shrink-0 font-mono text-2xs text-fg-4\">{selected.code}</span>\n                </>\n              ) : (\n                <span className=\"text-fg-4\">Choose a language</span>\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <ChevronsUpDown size={16} className=\"shrink-0 text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg-2\" />\n      </Combobox.Trigger>\n\n      <Combobox.Portal container={container}>\n        <Combobox.Positioner align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <Combobox.Popup\n            aria-label=\"Choose a language and region\"\n            className={cn(\n              \"flex w-(--anchor-width) max-w-(--available-width) sm:w-[max(var(--anchor-width),21rem)] origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:duration-100\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <div className=\"relative flex items-center border-b border-line\">\n              <Search size={14} className=\"pointer-events-none absolute left-3 text-fg-3\" />\n              <Combobox.Input\n                placeholder=\"Search in any language\"\n                aria-label=\"Search languages\"\n                className=\"h-10 w-full bg-transparent pr-3 pl-8 text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n              />\n            </div>\n            <Combobox.Empty className=\"empty:hidden\">\n              <div className=\"flex flex-col gap-0.5 px-3 py-6 text-center\">\n                <p className=\"truncate text-[13px] text-fg-2\">No languages match “{query.trim()}”</p>\n                <p className=\"text-[12px] text-fg-3\">Try the name in English, in the language itself, or a code like pt-BR.</p>\n              </div>\n            </Combobox.Empty>\n            <Combobox.List\n              ref={setList}\n              className=\"relative max-h-[min(calc(var(--available-height)-7rem),14rem)] scroll-pt-8 scroll-pb-1 overflow-y-auto overscroll-contain px-1 pb-1 outline-none empty:hidden\"\n            >\n              <motion.div aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line\" />\n              {groups.map((group) => (\n                <Combobox.Group key={group.value} items={group.items} className=\"block\">\n                  <Combobox.GroupLabel className=\"sticky top-0 z-[2] flex h-8 items-end bg-raised px-2 pb-1.5 font-mono text-2xs tracking-[0.08em] text-fg-3 uppercase select-none\">\n                    {group.label}\n                  </Combobox.GroupLabel>\n                  <Combobox.Collection>\n                    {(item: Item) => (\n                      <Combobox.Item\n                        key={`${group.value}:${item.code}`}\n                        value={item}\n                        className=\"group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2 rounded-lg pr-2 pl-2 text-[13px] outline-none select-none pointer-coarse:h-10\"\n                      >\n                        <span className=\"grid size-4 shrink-0 place-items-center\">\n                          <Combobox.ItemIndicator>\n                            <Check size={14} />\n                          </Combobox.ItemIndicator>\n                        </span>\n                        <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                          {/* Named in its own language, so the person who reads it can find it. */}\n                          <span lang={item.code} dir=\"auto\" className=\"shrink-0 truncate text-fg group-data-selected/item:font-medium\">\n                            {item.native}\n                          </span>\n                          {item.english !== item.native && <span className=\"min-w-0 truncate text-[12px] text-fg-3\">{item.english}</span>}\n                        </span>\n                        {item.code === deviceCode && (\n                          <span className=\"shrink-0 rounded-full border border-line-2 px-1.5 text-[10.5px] leading-4 text-fg-3\">Device</span>\n                        )}\n                      </Combobox.Item>\n                    )}\n                  </Combobox.Collection>\n                </Combobox.Group>\n              ))}\n            </Combobox.List>\n            {!hidePreview && samples && preview && (\n              // Follows the highlighted row, so formats can be compared before choosing. Instant: it moves with the arrow keys.\n              <div aria-hidden className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 border-t border-line bg-frame px-3 py-2.5 text-[12px] leading-[18px]\">\n                <span className=\"text-fg-4\">Date</span>\n                <span lang={preview.code} dir=\"auto\" className=\"truncate text-left text-fg-2 tabular\">\n                  {samples.date} · {samples.time}\n                </span>\n                <span className=\"text-fg-4\">Number</span>\n                <span lang={preview.code} dir=\"auto\" className=\"truncate text-left text-fg-2 tabular\">\n                  {samples.number} · {samples.currency}\n                </span>\n              </div>\n            )}\n          </Combobox.Popup>\n        </Combobox.Positioner>\n      </Combobox.Portal>\n    </Combobox.Root>\n  );\n}\n\nexport type LocalePreviewProps = Omit<React.ComponentProps<\"dl\">, \"children\"> & {\n  /** A locale code from the list, or any BCP 47 tag with a currency. */\n  locale: string | LocaleOption;\n  locales?: LocaleOption[];\n};\n\n/** How dates, times and numbers will look in a locale. Values roll over when it changes. */\nexport function LocalePreview({ locale, locales = COMMON_LOCALES, className, ...rest }: LocalePreviewProps) {\n  const reduce = !!useReducedMotion();\n  const option = typeof locale === \"string\" ? (locales.find((l) => l.code === locale) ?? { code: locale, currency: \"USD\" }) : locale;\n  const s = formatSamples(option);\n  const rows: [string, string][] = [\n    [\"Date\", s.date],\n    [\"Short date\", s.short],\n    [\"Time\", s.time],\n    [\"Number\", s.number],\n    [\"Currency\", s.currency],\n  ];\n\n  return (\n    <dl className={cn(\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-1.5 text-[12.5px] leading-[18px]\", className)} {...rest}>\n      {rows.map(([label, text], i) => (\n        <div key={label} className=\"contents\">\n          <dt className=\"text-fg-3\">{label}</dt>\n          <dd className=\"grid min-w-0 grid-cols-[minmax(0,1fr)] overflow-hidden text-fg tabular\">\n            <AnimatePresence initial={false}>\n              <motion.span\n                key={`${option.code}:${text}`}\n                lang={option.code}\n                dir=\"auto\"\n                suppressHydrationWarning\n                className=\"col-start-1 row-start-1 truncate text-left\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: 0.22, ease: ease.out, delay: reduce ? 0 : i * stagger.items } }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.out } }}\n              >\n                {text}\n              </motion.span>\n            </AnimatePresence>\n          </dd>\n        </div>\n      ))}\n    </dl>\n  );\n}\n\n// One highlight for the whole list: it glides after the pointer on a spring and\n// jumps for arrow keys, where motion would read as lag.\nfunction useGlide(reduce: boolean) {\n  const [list, setList] = useState<HTMLDivElement | null>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!list) return;\n    let keyboard = false;\n    let shown = false;\n    let running: AnimationPlaybackControls[] = [];\n    const stop = () => {\n      running.forEach((c) => c.stop());\n      running = [];\n    };\n    const place = () => {\n      const el = list.querySelector<HTMLElement>(\"[data-highlighted]\");\n      stop();\n      if (!el) {\n        shown = false;\n        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));\n        return;\n      }\n      // offsetTop ignores the popup's entrance scale, which would skew a measured rect.\n      let top = 0;\n      for (let n: HTMLElement | null = el; n && n !== list; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;\n      if (!shown || keyboard || reduce) {\n        y.jump(top);\n        height.jump(el.offsetHeight);\n        opacity.jump(1);\n      } else {\n        running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));\n        opacity.jump(1);\n      }\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onPointer = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    document.addEventListener(\"keydown\", onKey, true);\n    list.addEventListener(\"pointermove\", onPointer);\n    place();\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"keydown\", onKey, true);\n      list.removeEventListener(\"pointermove\", onPointer);\n      stop();\n    };\n  }, [list, reduce, y, height, opacity]);\n\n  return { setList, y, height, opacity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/locale-select"
}
