{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"phone-input","type":"registry:component","title":"Phone Input","description":"International phone field with a searchable country picker (emoji flags, dial codes, ~45 built-in countries or your own list), per-country grouping applied as you type without losing the caret, E.164 output with { country, national, valid }, paste-a-full-+number country detection, and a blur-time length check that shakes the field with an inline error.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/phone-input.tsx","type":"registry:component","target":"@components/motion/phone-input.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/phone-input\n\nimport { ChevronDown, Search } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ClipboardEvent,\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type PhoneCountry = {\n  /** ISO 3166-1 alpha-2 code, e.g. \"ID\". */\n  code: string;\n  name: string;\n  /** Dial code digits without \"+\", e.g. \"62\". */\n  dial: string;\n  /** Grouping for the national number: `#` is a digit, anything else a separator. */\n  pattern: string;\n  /** Valid national (significant) number length, `[min, max]` digits. */\n  length: [number, number];\n  /** Keep a leading 0 instead of treating it as the trunk prefix (Italy). */\n  keepLeadingZero?: boolean;\n};\n\nexport type PhoneValueMeta = {\n  country: PhoneCountry;\n  /** National significant number, digits only. */\n  national: string;\n  valid: boolean;\n};\n\n/** Compact built-in list. Patterns cover the common mobile format per country. */\nexport const PHONE_COUNTRIES: PhoneCountry[] = [\n  { code: \"ID\", name: \"Indonesia\", dial: \"62\", pattern: \"### #### #####\", length: [9, 12] },\n  { code: \"US\", name: \"United States\", dial: \"1\", pattern: \"(###) ###-####\", length: [10, 10] },\n  { code: \"GB\", name: \"United Kingdom\", dial: \"44\", pattern: \"#### ######\", length: [9, 10] },\n  { code: \"IN\", name: \"India\", dial: \"91\", pattern: \"##### #####\", length: [10, 10] },\n  { code: \"SG\", name: \"Singapore\", dial: \"65\", pattern: \"#### ####\", length: [8, 8] },\n  { code: \"MY\", name: \"Malaysia\", dial: \"60\", pattern: \"##-#### ####\", length: [9, 10] },\n  { code: \"JP\", name: \"Japan\", dial: \"81\", pattern: \"##-####-####\", length: [9, 10] },\n  { code: \"DE\", name: \"Germany\", dial: \"49\", pattern: \"### ########\", length: [7, 12] },\n  { code: \"BR\", name: \"Brazil\", dial: \"55\", pattern: \"(##) #####-####\", length: [10, 11] },\n  { code: \"AU\", name: \"Australia\", dial: \"61\", pattern: \"### ### ###\", length: [9, 9] },\n  { code: \"CA\", name: \"Canada\", dial: \"1\", pattern: \"(###) ###-####\", length: [10, 10] },\n  { code: \"MX\", name: \"Mexico\", dial: \"52\", pattern: \"## #### ####\", length: [10, 10] },\n  { code: \"FR\", name: \"France\", dial: \"33\", pattern: \"# ## ## ## ##\", length: [9, 9] },\n  { code: \"IT\", name: \"Italy\", dial: \"39\", pattern: \"### ### ####\", length: [6, 11], keepLeadingZero: true },\n  { code: \"ES\", name: \"Spain\", dial: \"34\", pattern: \"### ## ## ##\", length: [9, 9] },\n  { code: \"NL\", name: \"Netherlands\", dial: \"31\", pattern: \"# ########\", length: [9, 9] },\n  { code: \"BE\", name: \"Belgium\", dial: \"32\", pattern: \"### ## ## ##\", length: [8, 9] },\n  { code: \"CH\", name: \"Switzerland\", dial: \"41\", pattern: \"## ### ## ##\", length: [9, 9] },\n  { code: \"SE\", name: \"Sweden\", dial: \"46\", pattern: \"## ### ## ##\", length: [7, 9] },\n  { code: \"NO\", name: \"Norway\", dial: \"47\", pattern: \"### ## ###\", length: [8, 8] },\n  { code: \"DK\", name: \"Denmark\", dial: \"45\", pattern: \"## ## ## ##\", length: [8, 8] },\n  { code: \"IE\", name: \"Ireland\", dial: \"353\", pattern: \"## ### ####\", length: [7, 9] },\n  { code: \"PT\", name: \"Portugal\", dial: \"351\", pattern: \"### ### ###\", length: [9, 9] },\n  { code: \"PL\", name: \"Poland\", dial: \"48\", pattern: \"### ### ###\", length: [9, 9] },\n  { code: \"RU\", name: \"Russia\", dial: \"7\", pattern: \"### ###-##-##\", length: [10, 10] },\n  { code: \"TR\", name: \"Türkiye\", dial: \"90\", pattern: \"### ### ## ##\", length: [10, 10] },\n  { code: \"CN\", name: \"China\", dial: \"86\", pattern: \"### #### ####\", length: [11, 11] },\n  { code: \"HK\", name: \"Hong Kong\", dial: \"852\", pattern: \"#### ####\", length: [8, 8] },\n  { code: \"TW\", name: \"Taiwan\", dial: \"886\", pattern: \"### ### ###\", length: [9, 9] },\n  { code: \"KR\", name: \"South Korea\", dial: \"82\", pattern: \"##-####-####\", length: [9, 10] },\n  { code: \"TH\", name: \"Thailand\", dial: \"66\", pattern: \"## ### ####\", length: [8, 9] },\n  { code: \"VN\", name: \"Vietnam\", dial: \"84\", pattern: \"## ### ## ##\", length: [9, 10] },\n  { code: \"PH\", name: \"Philippines\", dial: \"63\", pattern: \"### ### ####\", length: [10, 10] },\n  { code: \"PK\", name: \"Pakistan\", dial: \"92\", pattern: \"### #######\", length: [10, 10] },\n  { code: \"BD\", name: \"Bangladesh\", dial: \"880\", pattern: \"####-######\", length: [10, 10] },\n  { code: \"AE\", name: \"United Arab Emirates\", dial: \"971\", pattern: \"## ### ####\", length: [8, 9] },\n  { code: \"SA\", name: \"Saudi Arabia\", dial: \"966\", pattern: \"## ### ####\", length: [9, 9] },\n  { code: \"EG\", name: \"Egypt\", dial: \"20\", pattern: \"### ### ####\", length: [10, 10] },\n  { code: \"NG\", name: \"Nigeria\", dial: \"234\", pattern: \"### ### ####\", length: [10, 10] },\n  { code: \"ZA\", name: \"South Africa\", dial: \"27\", pattern: \"## ### ####\", length: [9, 9] },\n  { code: \"KE\", name: \"Kenya\", dial: \"254\", pattern: \"### ######\", length: [9, 9] },\n  { code: \"AR\", name: \"Argentina\", dial: \"54\", pattern: \"## ####-####\", length: [10, 11] },\n  { code: \"CO\", name: \"Colombia\", dial: \"57\", pattern: \"### ### ####\", length: [10, 10] },\n  { code: \"CL\", name: \"Chile\", dial: \"56\", pattern: \"# #### ####\", length: [9, 9] },\n  { code: \"NZ\", name: \"New Zealand\", dial: \"64\", pattern: \"## ### ####\", length: [8, 10] },\n];\n\n/** Emoji flag from an ISO alpha-2 code via regional indicator symbols. */\nexport function countryFlag(code: string) {\n  const upper = code.toUpperCase();\n  if (!/^[A-Z]{2}$/.test(upper)) return \"\";\n  return String.fromCodePoint(\n    ...[...upper].map((char) => 0x1f1e6 + char.charCodeAt(0) - 65),\n  );\n}\n\n/** Groups national digits by the country pattern; overflow digits trail ungrouped. */\nexport function formatNational(digits: string, pattern: string) {\n  let out = \"\";\n  let index = 0;\n  for (const char of pattern) {\n    if (index >= digits.length) break;\n    if (char === \"#\") {\n      out += digits[index];\n      index += 1;\n    } else {\n      out += char;\n    }\n  }\n  return out + digits.slice(index);\n}\n\nfunction normalizeNational(digits: string, country: PhoneCountry) {\n  const trimmed =\n    !country.keepLeadingZero && digits.startsWith(\"0\") ? digits.slice(1) : digits;\n  return trimmed.slice(0, country.length[1]);\n}\n\n/** Finds the country for a full international number (longest dial code wins). */\nexport function detectCountry(\n  international: string,\n  countries: PhoneCountry[],\n  prefer?: string,\n): { country: PhoneCountry; national: string } | null {\n  const digits = international.replace(/\\D/g, \"\");\n  let best: PhoneCountry | null = null;\n  for (const country of countries) {\n    if (!digits.startsWith(country.dial)) continue;\n    if (\n      !best ||\n      country.dial.length > best.dial.length ||\n      (country.dial === best.dial && country.code === prefer)\n    )\n      best = country;\n  }\n  if (!best) return null;\n  return {\n    country: best,\n    national: normalizeNational(digits.slice(best.dial.length), best),\n  };\n}\n\nfunction isValidNational(national: string, country: PhoneCountry) {\n  return (\n    national.length >= country.length[0] && national.length <= country.length[1]\n  );\n}\n\nfunction toE164(national: string, country: PhoneCountry) {\n  return national ? `+${country.dial}${national}` : \"\";\n}\n\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\n\nexport interface PhoneInputProps {\n  /** Controlled E.164 value, e.g. \"+6281234567890\". Empty string for none. */\n  value?: string;\n  defaultValue?: string;\n  /** E.164 string (empty when there are no digits) plus parsed details. */\n  onChange?: (e164: string, meta: PhoneValueMeta) => void;\n  /** Controlled ISO country code. */\n  country?: string;\n  /** Initial ISO country code. Default \"ID\". */\n  defaultCountry?: string;\n  onCountryChange?: (country: PhoneCountry) => void;\n  /** Replace the built-in country list. */\n  countries?: PhoneCountry[];\n  label?: string;\n  placeholder?: string;\n  /** Override the built-in \"invalid number\" message, or force an error. */\n  error?: string | boolean;\n  hint?: string;\n  disabled?: boolean;\n  name?: string;\n  id?: string;\n  \"aria-label\"?: string;\n  onBlur?: () => void;\n  className?: string;\n}\n\n/**\n * International phone field: searchable country picker with emoji flags, a\n * national number formatted as you type (caret preserved), E.164 output, and a\n * blur-time length check that shakes the field when the number is invalid.\n */\nexport function PhoneInput({\n  value: valueProp,\n  defaultValue,\n  onChange,\n  country: countryProp,\n  defaultCountry = \"ID\",\n  onCountryChange,\n  countries = PHONE_COUNTRIES,\n  label,\n  placeholder,\n  error: errorProp,\n  hint,\n  disabled = false,\n  name,\n  id: idProp,\n  \"aria-label\": ariaLabel,\n  onBlur,\n  className,\n}: PhoneInputProps) {\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const inputId = idProp ?? `${uid}-input`;\n  const messageId = `${uid}-message`;\n\n  const fallback = countries[0] ?? PHONE_COUNTRIES[0];\n  const findCountry = useCallback(\n    (code: string | undefined) =>\n      countries.find((c) => c.code === code?.toUpperCase()),\n    [countries],\n  );\n\n  const [internalCountry, setInternalCountry] = useState(() => {\n    const initial = defaultValue\n      ? detectCountry(defaultValue, countries, defaultCountry)?.country.code\n      : undefined;\n    return initial ?? defaultCountry;\n  });\n  const countryCode = countryProp ?? internalCountry;\n  const country = findCountry(countryCode) ?? fallback;\n\n  const [internalNational, setInternalNational] = useState(() =>\n    defaultValue\n      ? (detectCountry(defaultValue, countries, defaultCountry)?.national ?? \"\")\n      : \"\",\n  );\n  const controlled = valueProp !== undefined;\n  const national = controlled\n    ? valueProp\n      ? (detectCountry(valueProp, [country], country.code)?.national ??\n        detectCountry(valueProp, countries, country.code)?.national ??\n        \"\")\n      : \"\"\n    : internalNational;\n\n  const formatted = formatNational(national, country.pattern);\n  const valid = isValidNational(national, country);\n\n  const [touched, setTouched] = useState(false);\n  const builtInError =\n    touched && national.length > 0 && !valid\n      ? `Enter a valid ${country.name} number`\n      : null;\n  const errorMessage =\n    typeof errorProp === \"string\" ? errorProp : builtInError;\n  const hasError = Boolean(errorProp) || Boolean(builtInError);\n\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const pendingCaret = useRef<number | null>(null);\n\n  // Restore the caret after the formatted value re-renders: the caret sits\n  // after the same count of digits it was after before formatting.\n  useLayoutEffect(() => {\n    const digitsBefore = pendingCaret.current;\n    const input = inputRef.current;\n    if (digitsBefore === null || !input) return;\n    pendingCaret.current = null;\n    let position = 0;\n    let seen = 0;\n    while (position < formatted.length && seen < digitsBefore) {\n      if (/\\d/.test(formatted[position])) seen += 1;\n      position += 1;\n    }\n    if (document.activeElement === input)\n      input.setSelectionRange(position, position);\n  });\n\n  const shake = useCallback(() => {\n    if (fieldRef.current && !reduce)\n      animate(fieldRef.current, SHAKE, { duration: 0.4, ease: EASE_OUT });\n  }, [reduce]);\n\n  const emit = (nextNational: string, nextCountry: PhoneCountry) => {\n    if (!controlled) setInternalNational(nextNational);\n    onChange?.(toE164(nextNational, nextCountry), {\n      country: nextCountry,\n      national: nextNational,\n      valid: isValidNational(nextNational, nextCountry),\n    });\n  };\n\n  const changeCountry = (next: PhoneCountry) => {\n    if (countryProp === undefined) setInternalCountry(next.code);\n    onCountryChange?.(next);\n  };\n\n  const commitDigits = (\n    rawDigits: string,\n    digitsBeforeCaret: number,\n    target: PhoneCountry,\n  ) => {\n    const next = normalizeNational(rawDigits, target);\n    const stripped =\n      !target.keepLeadingZero && rawDigits.startsWith(\"0\") ? 1 : 0;\n    pendingCaret.current = Math.min(\n      Math.max(0, digitsBeforeCaret - stripped),\n      next.length,\n    );\n    emit(next, target);\n  };\n\n  const applyInternational = (text: string) => {\n    const detected = detectCountry(text, countries, country.code);\n    if (!detected) {\n      shake();\n      return;\n    }\n    if (detected.country.code !== country.code) changeCountry(detected.country);\n    pendingCaret.current = detected.national.length;\n    emit(detected.national, detected.country);\n  };\n\n  const onInputChange = (raw: string, caret: number) => {\n    if (raw.trim().startsWith(\"+\")) {\n      applyInternational(raw);\n      return;\n    }\n    const digitsBefore = raw.slice(0, caret).replace(/\\D/g, \"\").length;\n    commitDigits(raw.replace(/\\D/g, \"\"), digitsBefore, country);\n  };\n\n  const onPaste = (event: ClipboardEvent<HTMLInputElement>) => {\n    const text = event.clipboardData.getData(\"text\").trim();\n    if (!text.startsWith(\"+\") && !text.startsWith(\"00\")) return;\n    event.preventDefault();\n    applyInternational(text.startsWith(\"00\") ? `+${text.slice(2)}` : text);\n  };\n\n  // Backspace/Delete next to a separator removes the neighbouring digit\n  // instead of the separator (which formatting would just put back).\n  const onInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    const input = event.currentTarget;\n    const start = input.selectionStart ?? 0;\n    const end = input.selectionEnd ?? 0;\n    if (start !== end) return;\n    if (event.key === \"Backspace\" && start > 0 && /\\D/.test(formatted[start - 1])) {\n      event.preventDefault();\n      const digitsBefore = formatted.slice(0, start).replace(/\\D/g, \"\").length;\n      if (digitsBefore === 0) return;\n      const next =\n        national.slice(0, digitsBefore - 1) + national.slice(digitsBefore);\n      pendingCaret.current = digitsBefore - 1;\n      emit(next, country);\n    } else if (\n      event.key === \"Delete\" &&\n      start < formatted.length &&\n      /\\D/.test(formatted[start])\n    ) {\n      event.preventDefault();\n      const digitsBefore = formatted.slice(0, start).replace(/\\D/g, \"\").length;\n      const next =\n        national.slice(0, digitsBefore) + national.slice(digitsBefore + 1);\n      pendingCaret.current = digitsBefore;\n      emit(next, country);\n    }\n  };\n\n  const message = errorMessage ?? hint;\n  const examplePlaceholder =\n    placeholder ?? formatNational(\"1\".repeat(country.length[1]), country.pattern).replace(/1/g, \"0\");\n\n  return (\n    <div className={cn(\"relative flex w-full flex-col gap-1.5\", className)}>\n      {label ? (\n        <label\n          htmlFor={inputId}\n          className=\"px-1 text-sm font-medium text-foreground\"\n        >\n          {label}\n        </label>\n      ) : null}\n\n      <div\n        ref={fieldRef}\n        data-state={hasError ? \"error\" : \"idle\"}\n        className={cn(\n          \"flex h-11 w-full items-center rounded-full border border-border bg-background transition-colors duration-200\",\n          \"focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/40\",\n          hasError &&\n            \"border-destructive ring-2 ring-destructive/25 focus-within:border-destructive focus-within:ring-destructive/25\",\n          disabled && \"opacity-60\",\n        )}\n      >\n        <CountryPicker\n          countries={countries}\n          country={country}\n          disabled={disabled}\n          reduce={Boolean(reduce)}\n          onSelect={(next) => {\n            changeCountry(next);\n            const renormalized = normalizeNational(national, next);\n            if (renormalized !== national || next.code !== country.code)\n              emit(renormalized, next);\n            requestAnimationFrame(() => inputRef.current?.focus());\n          }}\n        />\n        <span\n          aria-hidden\n          className=\"pl-2 text-sm tabular-nums text-muted-foreground\"\n        >\n          +{country.dial}\n        </span>\n        <input\n          ref={inputRef}\n          id={inputId}\n          name={name}\n          type=\"tel\"\n          inputMode=\"tel\"\n          autoComplete=\"tel-national\"\n          value={formatted}\n          disabled={disabled}\n          placeholder={examplePlaceholder}\n          aria-label={label ? undefined : (ariaLabel ?? \"Phone number\")}\n          aria-invalid={hasError || undefined}\n          aria-describedby={message ? messageId : undefined}\n          onChange={(event) =>\n            onInputChange(\n              event.target.value,\n              event.target.selectionStart ?? event.target.value.length,\n            )\n          }\n          onKeyDown={onInputKeyDown}\n          onPaste={onPaste}\n          onBlur={() => {\n            setTouched(true);\n            if (national.length > 0 && !valid) shake();\n            onBlur?.();\n          }}\n          className=\"h-full min-w-0 flex-1 bg-transparent pr-4 pl-1.5 text-base tabular-nums text-foreground caret-foreground outline-none placeholder:text-muted-foreground/50 disabled:cursor-not-allowed\"\n        />\n      </div>\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {message ? (\n          <motion.p\n            key={errorMessage ? \"error\" : \"hint\"}\n            id={messageId}\n            role={errorMessage ? \"alert\" : undefined}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.1 } }\n                : { opacity: 0, y: -4, transition: { duration: 0.12 } }\n            }\n            transition={{ duration: 0.2, ease: EASE_OUT }}\n            className={cn(\n              \"px-1 text-xs\",\n              errorMessage ? \"text-destructive\" : \"text-muted-foreground\",\n            )}\n          >\n            {message}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction CountryPicker({\n  countries,\n  country,\n  disabled,\n  reduce,\n  onSelect,\n}: {\n  countries: PhoneCountry[];\n  country: PhoneCountry;\n  disabled: boolean;\n  reduce: boolean;\n  onSelect: (country: PhoneCountry) => void;\n}) {\n  const uid = useId();\n  const listId = `${uid}-list`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n\n  const filtered = useMemo(() => {\n    const q = query.trim().toLowerCase().replace(/^\\+/, \"\");\n    if (!q) return countries;\n    return countries.filter(\n      (c) =>\n        c.name.toLowerCase().includes(q) ||\n        c.code.toLowerCase() === q ||\n        c.dial.startsWith(q),\n    );\n  }, [countries, query]);\n\n  const close = useCallback((restoreFocus = false) => {\n    setOpen(false);\n    setQuery(\"\");\n    if (restoreFocus) buttonRef.current?.focus();\n  }, []);\n  const dismiss = useCallback(() => close(false), [close]);\n  useDismiss(open, dismiss, rootRef, { escape: false });\n\n  const openList = () => {\n    if (disabled) return;\n    const index = countries.findIndex((c) => c.code === country.code);\n    setActive(Math.max(0, index));\n    setOpen(true);\n  };\n\n  useEffect(() => {\n    if (!open) return;\n    requestAnimationFrame(() => searchRef.current?.focus());\n  }, [open]);\n\n  const activeIndex = Math.min(active, filtered.length - 1);\n  const activeCountry = filtered[activeIndex];\n\n  useEffect(() => {\n    if (!open || !activeCountry) return;\n    const list = listRef.current;\n    const item = list?.querySelector<HTMLElement>(\n      `[data-code=\"${activeCountry.code}\"]`,\n    );\n    if (!list || !item) return;\n    if (item.offsetTop < list.scrollTop) list.scrollTop = item.offsetTop - 4;\n    else if (item.offsetTop + item.offsetHeight > list.scrollTop + list.clientHeight)\n      list.scrollTop = item.offsetTop + item.offsetHeight - list.clientHeight + 4;\n  }, [activeCountry, open]);\n\n  const pick = (next: PhoneCountry) => {\n    close(false);\n    onSelect(next);\n  };\n\n  const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        setActive((a) => (filtered.length ? (Math.min(a, filtered.length - 1) + 1) % filtered.length : 0));\n        return;\n      case \"ArrowUp\":\n        event.preventDefault();\n        setActive((a) =>\n          filtered.length\n            ? (Math.min(a, filtered.length - 1) - 1 + filtered.length) % filtered.length\n            : 0,\n        );\n        return;\n      case \"Home\":\n        event.preventDefault();\n        setActive(0);\n        return;\n      case \"End\":\n        event.preventDefault();\n        setActive(Math.max(0, filtered.length - 1));\n        return;\n      case \"Enter\":\n        event.preventDefault();\n        if (activeCountry) pick(activeCountry);\n        return;\n      case \"Escape\":\n        event.preventDefault();\n        event.stopPropagation();\n        close(true);\n        return;\n      case \"Tab\":\n        close(false);\n        return;\n    }\n  };\n\n  return (\n    <div ref={rootRef} className=\"relative h-full shrink-0\">\n      <button\n        ref={buttonRef}\n        type=\"button\"\n        disabled={disabled}\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        aria-label={`Country: ${country.name} (+${country.dial})`}\n        onClick={() => (open ? close(false) : openList())}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n            event.preventDefault();\n            openList();\n          }\n        }}\n        className=\"flex h-full items-center gap-1 rounded-l-full border-r border-border pr-2 pl-3.5 outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted disabled:cursor-not-allowed\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={country.code}\n            aria-hidden\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.08 } }\n                : { opacity: 0, y: -10, transition: { duration: 0.1 } }\n            }\n            transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n            className=\"text-lg leading-none\"\n          >\n            {countryFlag(country.code)}\n          </motion.span>\n        </AnimatePresence>\n        <motion.span\n          aria-hidden\n          animate={{ rotate: open && !reduce ? 180 : 0 }}\n          transition={reduce ? { duration: 0 } : SPRING_SWAP}\n          className=\"grid text-muted-foreground\"\n        >\n          <ChevronDown className=\"size-3.5\" />\n        </motion.span>\n      </button>\n\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.1 } }\n                : {\n                    opacity: 0,\n                    y: -4,\n                    scale: 0.98,\n                    transition: { duration: 0.12, ease: EASE_OUT },\n                  }\n            }\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            style={{ transformOrigin: \"top left\" }}\n            className=\"absolute top-full left-0 z-30 mt-2 w-72 overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg\"\n          >\n            <div className=\"flex items-center gap-2 border-b border-border px-3\">\n              <Search aria-hidden className=\"size-4 shrink-0 text-muted-foreground\" />\n              <input\n                ref={searchRef}\n                type=\"text\"\n                role=\"combobox\"\n                aria-label=\"Search countries\"\n                aria-autocomplete=\"list\"\n                aria-expanded\n                aria-controls={listId}\n                aria-activedescendant={\n                  activeCountry ? `${listId}-${activeCountry.code}` : undefined\n                }\n                autoComplete=\"off\"\n                spellCheck={false}\n                value={query}\n                placeholder=\"Search country or code…\"\n                onChange={(event) => {\n                  setQuery(event.target.value);\n                  setActive(0);\n                }}\n                onKeyDown={onSearchKeyDown}\n                className=\"h-10 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground\"\n              />\n            </div>\n            <div\n              ref={listRef}\n              id={listId}\n              role=\"listbox\"\n              aria-label=\"Countries\"\n              className=\"relative isolate max-h-56 overflow-y-auto overscroll-contain p-1 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n            >\n              {filtered.length === 0 ? (\n                <p className=\"px-3 py-6 text-center text-sm text-muted-foreground\">\n                  No countries found.\n                </p>\n              ) : (\n                filtered.map((item, index) => {\n                  const isActive = index === activeIndex;\n                  const selected = item.code === country.code;\n                  return (\n                    <button\n                      key={item.code}\n                      id={`${listId}-${item.code}`}\n                      data-code={item.code}\n                      type=\"button\"\n                      role=\"option\"\n                      tabIndex={-1}\n                      aria-selected={selected}\n                      onMouseDown={(event) => event.preventDefault()}\n                      onPointerMove={() => setActive(index)}\n                      onClick={() => pick(item)}\n                      className={cn(\n                        \"relative flex w-full items-center gap-2.5 rounded-xl px-2.5 py-1.5 text-left text-sm outline-none\",\n                        isActive ? \"text-foreground\" : \"text-muted-foreground\",\n                      )}\n                    >\n                      {isActive ? (\n                        <motion.span\n                          aria-hidden\n                          layoutId={`${uid}-active`}\n                          transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                          className=\"absolute inset-0 -z-10 rounded-xl bg-muted\"\n                        />\n                      ) : null}\n                      <span aria-hidden className=\"text-base leading-none\">\n                        {countryFlag(item.code)}\n                      </span>\n                      <span\n                        className={cn(\n                          \"min-w-0 flex-1 truncate\",\n                          selected && \"font-medium text-foreground\",\n                        )}\n                      >\n                        {item.name}\n                      </span>\n                      <span className=\"tabular-nums text-xs text-muted-foreground\">\n                        +{item.dial}\n                      </span>\n                    </button>\n                  );\n                })\n              )}\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-dismiss.ts","type":"registry:hook","target":"@lib/hooks/use-dismiss.ts","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}