{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phone-field",
  "type": "registry:component",
  "title": "Phone Field",
  "description": "A phone number field with a searchable country picker: it formats the number the local way as you type, drops the habitual leading zero, and spots the country when you paste a +44.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "@base-ui/react",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/phone-field.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { Popover } from \"@base-ui/react/popover\";\nimport { CheckIcon, ChevronDownIcon, CircleCheckIcon, SearchIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ChangeEvent, type ClipboardEvent, type KeyboardEvent, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Country {\n  iso: string;\n  name: string;\n  dial: string;\n  format: string;\n  lengths: number[];\n  trunk?: boolean;\n  example: string;\n}\n\nexport const COUNTRIES: Country[] = [\n  { iso: \"US\", name: \"United States\", dial: \"1\", format: \"(###) ###-####\", lengths: [10], example: \"4155550132\" },\n  { iso: \"GB\", name: \"United Kingdom\", dial: \"44\", format: \"#### ######\", lengths: [10], trunk: true, example: \"7700900123\" },\n  { iso: \"CA\", name: \"Canada\", dial: \"1\", format: \"(###) ###-####\", lengths: [10], example: \"6045550199\" },\n  { iso: \"DE\", name: \"Germany\", dial: \"49\", format: \"### ########\", lengths: [10, 11], trunk: true, example: \"15123456789\" },\n  { iso: \"FR\", name: \"France\", dial: \"33\", format: \"# ## ## ## ##\", lengths: [9], trunk: true, example: \"612345678\" },\n  { iso: \"ES\", name: \"Spain\", dial: \"34\", format: \"### ### ###\", lengths: [9], example: \"612345678\" },\n  { iso: \"IT\", name: \"Italy\", dial: \"39\", format: \"### ### ####\", lengths: [9, 10], example: \"3123456789\" },\n  { iso: \"NL\", name: \"Netherlands\", dial: \"31\", format: \"# ## ## ## ##\", lengths: [9], trunk: true, example: \"612345678\" },\n  { iso: \"SE\", name: \"Sweden\", dial: \"46\", format: \"## ### ## ##\", lengths: [9], trunk: true, example: \"701234567\" },\n  { iso: \"IE\", name: \"Ireland\", dial: \"353\", format: \"## ### ####\", lengths: [9], trunk: true, example: \"851234567\" },\n  { iso: \"PT\", name: \"Portugal\", dial: \"351\", format: \"### ### ###\", lengths: [9], example: \"912345678\" },\n  { iso: \"CH\", name: \"Switzerland\", dial: \"41\", format: \"## ### ## ##\", lengths: [9], trunk: true, example: \"781234567\" },\n  { iso: \"IN\", name: \"India\", dial: \"91\", format: \"##### #####\", lengths: [10], trunk: true, example: \"9812345678\" },\n  { iso: \"JP\", name: \"Japan\", dial: \"81\", format: \"##-####-####\", lengths: [10], trunk: true, example: \"9012345678\" },\n  { iso: \"KR\", name: \"South Korea\", dial: \"82\", format: \"##-####-####\", lengths: [9, 10], trunk: true, example: \"1012345678\" },\n  { iso: \"CN\", name: \"China\", dial: \"86\", format: \"### #### ####\", lengths: [11], example: \"13812345678\" },\n  { iso: \"SG\", name: \"Singapore\", dial: \"65\", format: \"#### ####\", lengths: [8], example: \"81234567\" },\n  { iso: \"AU\", name: \"Australia\", dial: \"61\", format: \"### ### ###\", lengths: [9], trunk: true, example: \"412345678\" },\n  { iso: \"NZ\", name: \"New Zealand\", dial: \"64\", format: \"## ### ####\", lengths: [8, 9], trunk: true, example: \"211234567\" },\n  { iso: \"BR\", name: \"Brazil\", dial: \"55\", format: \"(##) #####-####\", lengths: [10, 11], example: \"11912345678\" },\n  { iso: \"MX\", name: \"Mexico\", dial: \"52\", format: \"## #### ####\", lengths: [10], example: \"5512345678\" },\n  { iso: \"ZA\", name: \"South Africa\", dial: \"27\", format: \"## ### ####\", lengths: [9], trunk: true, example: \"711234567\" },\n  { iso: \"NG\", name: \"Nigeria\", dial: \"234\", format: \"### ### ####\", lengths: [10], trunk: true, example: \"8021234567\" },\n  { iso: \"AE\", name: \"United Arab Emirates\", dial: \"971\", format: \"## ### ####\", lengths: [9], trunk: true, example: \"501234567\" },\n];\n\nconst THE = new Set([\"US\", \"GB\", \"NL\", \"AE\"]);\n\nconst flag = (iso: string) => String.fromCodePoint(...[...iso.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65));\n\nfunction format(digits: string, pattern: string) {\n  let out = \"\";\n  let i = 0;\n  for (const ch of pattern) {\n    if (i >= digits.length) break;\n    if (ch === \"#\") out += digits[i++];\n    else out += ch;\n  }\n  return out + digits.slice(i);\n}\n\nexport interface PhoneValue {\n  country: string;\n  national: string;\n  e164: string | null;\n}\n\nexport interface PhoneFieldProps {\n  defaultCountry?: string;\n  onValueChange?: (value: PhoneValue) => void;\n  countries?: Country[];\n  label?: string;\n  className?: string;\n}\n\nexport function PhoneField({ defaultCountry = \"GB\", onValueChange, countries = COUNTRIES, label = \"Phone number\", className }: PhoneFieldProps) {\n  const [iso, setIso] = useState(defaultCountry);\n  const [digits, setDigits] = useState(\"\");\n  const [touched, setTouched] = useState(false);\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const caret = useRef<number | null>(null);\n  const reduce = useReducedMotion();\n  const id = useId();\n  const country = countries.find((c) => c.iso === iso) ?? countries[0];\n  const valid = country.lengths.includes(digits.length);\n  const short = digits.length > 0 && digits.length < Math.min(...country.lengths);\n  const showError = touched && short;\n\n  const results = useMemo(() => {\n    const q = query.trim().toLowerCase().replace(/^\\+/, \"\");\n    if (!q) return countries;\n    return countries\n      .filter((c) => c.name.toLowerCase().includes(q) || c.iso.toLowerCase() === q || c.dial.startsWith(q))\n      .sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q)));\n  }, [countries, query]);\n\n  useLayoutEffect(() => {\n    if (caret.current === null || !inputRef.current) return;\n    inputRef.current.setSelectionRange(caret.current, caret.current);\n    caret.current = null;\n  });\n\n  const emit = (nextIso: string, nextDigits: string) => {\n    const c = countries.find((x) => x.iso === nextIso) ?? country;\n    onValueChange?.({ country: nextIso, national: nextDigits, e164: c.lengths.includes(nextDigits.length) ? `+${c.dial}${nextDigits}` : null });\n  };\n\n  const setNumber = (next: string, nextIso = iso) => {\n    const c = countries.find((x) => x.iso === nextIso) ?? country;\n    const clean = (c.trunk ? next.replace(/^0/, \"\") : next).slice(0, Math.max(...c.lengths));\n    setDigits(clean);\n    emit(nextIso, clean);\n    return clean;\n  };\n\n  const onChange = (event: ChangeEvent<HTMLInputElement>) => {\n    const el = event.target;\n    const at = el.selectionStart ?? el.value.length;\n    let before = el.value.slice(0, at).replace(/\\D/g, \"\").length;\n    const raw = el.value.replace(/\\D/g, \"\");\n    if (country.trunk && raw.startsWith(\"0\")) before = Math.max(0, before - 1);\n    let next = raw;\n    const native = event.nativeEvent as InputEvent;\n    const plain = country.trunk ? raw.replace(/^0/, \"\") : raw;\n    if (native.inputType === \"deleteContentBackward\" && plain === digits && before > 0) {\n      next = plain.slice(0, before - 1) + plain.slice(before);\n      before -= 1;\n    }\n    const clean = setNumber(next);\n    const formatted = format(clean, country.format);\n    let pos = 0;\n    let seen = 0;\n    while (pos < formatted.length && seen < Math.min(before, clean.length)) {\n      if (/\\d/.test(formatted[pos])) seen++;\n      pos++;\n    }\n    caret.current = pos;\n  };\n\n  const onPaste = (event: ClipboardEvent<HTMLInputElement>) => {\n    const text = event.clipboardData.getData(\"text\").trim();\n    const intl = text.replace(/[^\\d+]/g, \"\").replace(/^00/, \"+\");\n    if (!intl.startsWith(\"+\")) return;\n    event.preventDefault();\n    const num = intl.slice(1);\n    const matches = countries.filter((c) => num.startsWith(c.dial)).sort((a, b) => b.dial.length - a.dial.length);\n    const pick = matches.find((c) => c.iso === iso) ?? matches[0];\n    if (!pick) return;\n    setIso(pick.iso);\n    setNumber(num.slice(pick.dial.length), pick.iso);\n    setTouched(true);\n  };\n\n  const choose = (c: Country) => {\n    setIso(c.iso);\n    setOpen(false);\n    setQuery(\"\");\n    setNumber(digits, c.iso);\n    requestAnimationFrame(() => inputRef.current?.focus());\n  };\n\n  const onSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      const next = (active + (event.key === \"ArrowDown\" ? 1 : -1) + results.length) % Math.max(results.length, 1);\n      setActive(next);\n      listRef.current?.querySelector(`[data-index=\"${next}\"]`)?.scrollIntoView({ block: \"nearest\" });\n    } else if (event.key === \"Enter\" && results[active]) {\n      event.preventDefault();\n      choose(results[active]);\n    }\n  };\n\n  const missing = Math.min(...country.lengths) - digits.length;\n  const place = THE.has(country.iso) ? `the ${country.name}` : country.name;\n  const hint = showError\n    ? `${missing} digit${missing === 1 ? \"\" : \"s\"} short for ${place}`\n    : valid\n      ? `+${country.dial} ${format(digits, country.format)}`\n      : digits.length\n        ? `${missing} more to go`\n        : \"We'll text a code to this number.\";\n\n  return (\n    <div className={cn(\"grid w-full min-w-0 gap-2\", className)}>\n      <label className=\"font-medium text-sm\" htmlFor={id}>\n        {label}\n      </label>\n      <div\n        className={cn(\n          \"flex h-9 items-stretch rounded-lg border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 ring-ring/24 transition-shadow has-focus-visible:border-ring has-focus-visible:ring-[3px] dark:bg-input/32\",\n          showError && \"border-destructive/40 has-focus-visible:border-destructive/64 has-focus-visible:ring-destructive/16\",\n        )}\n      >\n        <Popover.Root\n          onOpenChange={(next) => {\n            setOpen(next);\n            if (next) setActive(Math.max(0, results.findIndex((c) => c.iso === iso)));\n            else setQuery(\"\");\n          }}\n          open={open}\n        >\n          <Popover.Trigger\n            aria-label={`Country: ${country.name} (+${country.dial})`}\n            className=\"flex shrink-0 cursor-pointer items-center gap-1.5 rounded-l-[calc(var(--radius-lg)-1px)] border-r border-input pr-2 pl-2.5 text-sm outline-none transition-colors hover:bg-accent focus-visible:bg-accent data-popup-open:bg-accent\"\n          >\n            <span aria-hidden className=\"text-base leading-none\">\n              {flag(country.iso)}\n            </span>\n            <span className=\"text-muted-foreground tabular-nums\">+{country.dial}</span>\n            <ChevronDownIcon className=\"size-3.5 text-muted-foreground\" />\n          </Popover.Trigger>\n          <Popover.Portal>\n            <Popover.Positioner align=\"start\" className=\"z-50\" sideOffset={6}>\n              <Popover.Popup\n                className=\"w-72 origin-(--transform-origin) overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg/5 outline-none transition-[scale,opacity] duration-150 ease-out data-ending-style:scale-98 data-ending-style:opacity-0 data-starting-style:scale-98 data-starting-style:opacity-0\"\n                initialFocus={searchRef}\n              >\n                <div className=\"flex items-center gap-2 border-b px-3\">\n                  <SearchIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n                  <input\n                    aria-activedescendant={results[active] ? `${id}-c-${results[active].iso}` : undefined}\n                    aria-controls={`${id}-list`}\n                    aria-label=\"Search countries\"\n                    className=\"h-10 w-full bg-transparent text-base outline-none placeholder:text-muted-foreground/72 sm:text-sm\"\n                    onChange={(event) => {\n                      setQuery(event.target.value);\n                      setActive(0);\n                    }}\n                    onKeyDown={onSearchKey}\n                    placeholder=\"Country or code\"\n                    ref={searchRef}\n                    role=\"combobox\"\n                    aria-expanded\n                    value={query}\n                  />\n                </div>\n                <div className=\"max-h-64 overflow-y-auto p-1\" id={`${id}-list`} ref={listRef} role=\"listbox\">\n                  {results.length === 0 && <p className=\"px-2 py-6 text-center text-muted-foreground text-sm\">No country matches “{query}”</p>}\n                  {results.map((c, i) => (\n                    <div\n                      aria-selected={c.iso === iso}\n                      className=\"flex h-9 cursor-pointer select-none items-center gap-2.5 rounded-lg px-2 text-sm data-[active=true]:bg-accent\"\n                      data-active={i === active}\n                      data-index={i}\n                      id={`${id}-c-${c.iso}`}\n                      key={c.iso}\n                      onClick={() => choose(c)}\n                      onMouseMove={() => setActive(i)}\n                      role=\"option\"\n                    >\n                      <span aria-hidden className=\"text-base leading-none\">\n                        {flag(c.iso)}\n                      </span>\n                      <span className=\"min-w-0 flex-1 truncate\">{c.name}</span>\n                      <span className=\"text-muted-foreground text-xs tabular-nums\">+{c.dial}</span>\n                      <CheckIcon className={cn(\"size-3.5 shrink-0\", c.iso === iso ? \"opacity-100\" : \"opacity-0\")} />\n                    </div>\n                  ))}\n                </div>\n              </Popover.Popup>\n            </Popover.Positioner>\n          </Popover.Portal>\n        </Popover.Root>\n        <input\n          aria-describedby={`${id}-hint`}\n          aria-invalid={showError || undefined}\n          autoComplete=\"tel-national\"\n          className=\"w-0 min-w-0 flex-1 bg-transparent px-2.5 text-base tabular-nums outline-none placeholder:text-muted-foreground/60 sm:text-sm\"\n          id={id}\n          inputMode=\"tel\"\n          maxLength={country.format.length + 4}\n          onBlur={() => setTouched(true)}\n          onChange={onChange}\n          onFocus={() => setTouched(false)}\n          onPaste={onPaste}\n          placeholder={format(country.example, country.format)}\n          ref={inputRef}\n          type=\"tel\"\n          value={format(digits, country.format)}\n        />\n        <span className=\"flex w-9 shrink-0 items-center justify-center\">\n          <AnimatePresence initial={false}>\n            {valid && (\n              <motion.span\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.1 } }}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                transition={{ type: \"spring\", stiffness: 600, damping: 30 }}\n              >\n                <CircleCheckIcon aria-hidden className=\"size-4 text-success-foreground\" />\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n      </div>\n      <p\n        aria-live=\"polite\"\n        className={cn(\"text-xs tabular-nums\", showError ? \"text-destructive-foreground\" : valid ? \"text-success-foreground\" : \"text-muted-foreground\")}\n        id={`${id}-hint`}\n      >\n        {valid && <span className=\"sr-only\">Valid number: </span>}\n        {hint}\n      </p>\n    </div>\n  );\n}\n"
    }
  ]
}