{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phone-input",
  "type": "registry:ui",
  "title": "Phone input",
  "description": "A phone field with a country picker, formatting as you type, and E.164 output.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/phone-input/phone-input.tsx",
      "type": "registry:component",
      "target": "@components/xzero/phone-input/phone-input.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ClipboardEvent as ReactClipboardEvent, CSSProperties, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Check, ChevronDown, Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./phone-input.module.css\";\n\n/* ------------------------------------------------------------------------------------------------\n * Country table. Patterns format the national significant number (no trunk prefix); `#` is a digit.\n * Valid lengths are the digit capacities of the patterns. Examples are realistic mobile numbers and\n * double as the placeholder. Compact on purpose: no metadata download, no dependency.\n * ---------------------------------------------------------------------------------------------- */\n\nexport interface PhoneCountry {\n  /** ISO 3166-1 alpha-2 code, such as \"US\". */\n  iso: string;\n  name: string;\n  /** Country calling code without the plus, such as \"44\". */\n  dial: string;\n  /** National formats from shortest to longest. `#` is a digit, anything else is a separator. */\n  patterns: string[];\n  /** A national number people may type before the number itself, such as \"0\" in \"07911 123456\". */\n  trunk: string;\n  /** A realistic national number, used for the placeholder and the typing guide. */\n  example: string;\n  /** Area codes that pick this country when several share a calling code, such as Canada inside +1. */\n  areaCodes?: string[];\n}\n\nconst country = (iso: string, name: string, dial: string, patterns: string | string[], example: string, trunk = \"0\", areaCodes?: string[]): PhoneCountry =>\n  ({ iso, name, dial, patterns: Array.isArray(patterns) ? patterns : [patterns], example, trunk, areaCodes });\n\nconst CA_AREA_CODES = \"204 226 236 249 250 263 289 306 343 354 365 367 368 382 387 403 416 418 428 431 437 438 450 468 474 506 514 519 548 579 581 584 587 604 613 639 647 672 683 705 709 742 753 778 780 782 807 819 825 867 873 879 902 905\".split(\" \");\n\nexport const PHONE_COUNTRIES: PhoneCountry[] = [\n  country(\"US\", \"United States\", \"1\", \"(###) ###-####\", \"2015550123\", \"1\"),\n  country(\"CA\", \"Canada\", \"1\", \"(###) ###-####\", \"5065550123\", \"1\", CA_AREA_CODES),\n  country(\"MX\", \"Mexico\", \"52\", \"## #### ####\", \"2221234567\", \"\"),\n  country(\"BR\", \"Brazil\", \"55\", [\"(##) ####-####\", \"(##) #####-####\"], \"11961234567\"),\n  country(\"AR\", \"Argentina\", \"54\", \"## ####-####\", \"1123456789\"),\n  country(\"CO\", \"Colombia\", \"57\", \"### ### ####\", \"3211234567\", \"\"),\n  country(\"CL\", \"Chile\", \"56\", \"# #### ####\", \"221234567\", \"\"),\n  country(\"PE\", \"Peru\", \"51\", \"### ### ###\", \"912345678\"),\n  country(\"GB\", \"United Kingdom\", \"44\", \"#### ######\", \"7400123456\"),\n  country(\"IE\", \"Ireland\", \"353\", \"## ### ####\", \"850123456\"),\n  country(\"FR\", \"France\", \"33\", \"# ## ## ## ##\", \"612345678\"),\n  country(\"DE\", \"Germany\", \"49\", [\"### #######\", \"### ########\"], \"15123456789\"),\n  country(\"ES\", \"Spain\", \"34\", \"### ## ## ##\", \"612345678\", \"\"),\n  country(\"IT\", \"Italy\", \"39\", [\"### ### ###\", \"### ### ####\"], \"3123456789\", \"\"),\n  country(\"PT\", \"Portugal\", \"351\", \"### ### ###\", \"912345678\", \"\"),\n  country(\"NL\", \"Netherlands\", \"31\", \"# ########\", \"612345678\"),\n  country(\"BE\", \"Belgium\", \"32\", \"### ## ## ##\", \"470123456\"),\n  country(\"CH\", \"Switzerland\", \"41\", \"## ### ## ##\", \"781234567\"),\n  country(\"AT\", \"Austria\", \"43\", [\"### #######\", \"### ########\"], \"6641234567\"),\n  country(\"SE\", \"Sweden\", \"46\", \"## ### ## ##\", \"701234567\"),\n  country(\"NO\", \"Norway\", \"47\", \"### ## ###\", \"40612345\", \"\"),\n  country(\"DK\", \"Denmark\", \"45\", \"## ## ## ##\", \"32123456\", \"\"),\n  country(\"FI\", \"Finland\", \"358\", [\"## ### ####\", \"## ### #####\"], \"412345678\"),\n  country(\"PL\", \"Poland\", \"48\", \"### ### ###\", \"512345678\", \"\"),\n  country(\"CZ\", \"Czechia\", \"420\", \"### ### ###\", \"601123456\", \"\"),\n  country(\"GR\", \"Greece\", \"30\", \"### ### ####\", \"6912345678\", \"\"),\n  country(\"UA\", \"Ukraine\", \"380\", \"## ### ## ##\", \"501234567\"),\n  country(\"TR\", \"Turkey\", \"90\", \"### ### ## ##\", \"5012345678\"),\n  country(\"IL\", \"Israel\", \"972\", \"##-###-####\", \"502345678\"),\n  country(\"AE\", \"United Arab Emirates\", \"971\", \"## ### ####\", \"501234567\"),\n  country(\"SA\", \"Saudi Arabia\", \"966\", \"## ### ####\", \"512345678\"),\n  country(\"EG\", \"Egypt\", \"20\", \"### ### ####\", \"1001234567\"),\n  country(\"NG\", \"Nigeria\", \"234\", \"### ### ####\", \"8021234567\"),\n  country(\"KE\", \"Kenya\", \"254\", \"### ######\", \"712123456\"),\n  country(\"ZA\", \"South Africa\", \"27\", \"## ### ####\", \"711234567\"),\n  country(\"IN\", \"India\", \"91\", \"#####-#####\", \"8123456789\"),\n  country(\"PK\", \"Pakistan\", \"92\", \"### #######\", \"3012345678\"),\n  country(\"CN\", \"China\", \"86\", \"### #### ####\", \"13123456789\"),\n  country(\"JP\", \"Japan\", \"81\", \"##-####-####\", \"9012345678\"),\n  country(\"KR\", \"South Korea\", \"82\", [\"##-###-####\", \"##-####-####\"], \"1020000000\"),\n  country(\"TW\", \"Taiwan\", \"886\", \"### ### ###\", \"912345678\"),\n  country(\"HK\", \"Hong Kong\", \"852\", \"#### ####\", \"51234567\", \"\"),\n  country(\"SG\", \"Singapore\", \"65\", \"#### ####\", \"81234567\", \"\"),\n  country(\"PH\", \"Philippines\", \"63\", \"### ### ####\", \"9051234567\"),\n  country(\"ID\", \"Indonesia\", \"62\", [\"###-###-####\", \"###-####-####\", \"###-####-#####\"], \"81234567890\"),\n  country(\"TH\", \"Thailand\", \"66\", \"## ### ####\", \"812345678\"),\n  country(\"VN\", \"Vietnam\", \"84\", \"## ### ## ##\", \"912345678\"),\n  country(\"AU\", \"Australia\", \"61\", \"### ### ###\", \"412345678\"),\n  country(\"NZ\", \"New Zealand\", \"64\", [\"# ### ####\", \"## ### ####\", \"## #### ####\"], \"211234567\"),\n];\n\nconst BY_ISO = new Map(PHONE_COUNTRIES.map(entry => [entry.iso, entry]));\nconst capacity = (pattern: string) => pattern.split(\"#\").length - 1;\nconst lengthsOf = (entry: PhoneCountry) => entry.patterns.map(capacity);\n/** A typed trunk prefix earns its own room; without one the longest valid length is the cap. */\nconst capDigits = (entry: PhoneCountry, digits: string) => digits.slice(0, splitTrunk(entry, digits)[0].length + Math.max(...lengthsOf(entry)));\nconst onlyDigits = (text: string) => text.replace(/\\D/g, \"\");\n\n/** Regional indicator letters. Platforms without flag glyphs show the two letters, which still read. */\nexport const flagOf = (iso: string) => String.fromCodePoint(...[...iso.toUpperCase()].map(char => 0x1f1e6 + char.charCodeAt(0) - 65));\n\nfunction splitTrunk(entry: PhoneCountry, digits: string): [string, string] {\n  return entry.trunk && digits.startsWith(entry.trunk) ? [entry.trunk, digits.slice(entry.trunk.length)] : [\"\", digits];\n}\n\n/** Separators only print while digits follow, so a half typed number never ends in a dangling \") \" or \"-\". */\nfunction applyPattern(digits: string, pattern: string) {\n  let out = \"\", index = 0;\n  for (const char of pattern) {\n    if (index >= digits.length) break;\n    out += char === \"#\" ? digits[index++] : char;\n  }\n  return out + digits.slice(index);\n}\n\nfunction patternFor(entry: PhoneCountry, length: number) {\n  return entry.patterns.find(pattern => capacity(pattern) >= length) ?? entry.patterns[entry.patterns.length - 1]!;\n}\n\n/** Formats national digits as typed, keeping a typed trunk prefix: \"07911123456\" in the UK reads \"07911 123456\". */\nexport function formatNational(entry: PhoneCountry, digits: string) {\n  const [trunk, rest] = splitTrunk(entry, digits);\n  const pattern = patternFor(entry, rest.length);\n  const body = applyPattern(rest, pattern);\n  if (!trunk) return body;\n  if (!rest) return trunk;\n  return trunk + (/^#/.test(pattern) ? \"\" : \" \") + body;\n}\n\nexport type PhoneStatus = \"empty\" | \"incomplete\" | \"valid\" | \"too-long\";\n\nfunction statusOf(entry: PhoneCountry, digits: string): PhoneStatus {\n  const length = splitTrunk(entry, digits)[1].length;\n  const lengths = lengthsOf(entry);\n  if (!length) return \"empty\";\n  if (lengths.includes(length)) return \"valid\";\n  return length > Math.max(...lengths) ? \"too-long\" : \"incomplete\";\n}\n\nconst toE164 = (entry: PhoneCountry, digits: string) => { const rest = splitTrunk(entry, digits)[1]; return rest ? `+${entry.dial}${rest}` : \"\"; };\n\n/** Reads \"+44 (0)7911 123456\", \"0044 7911…\", or \"+14165550123\" into a country and national digits. Longest calling code wins. */\nexport function parsePhoneNumber(input: string, pool: PhoneCountry[] = PHONE_COUNTRIES): { country: PhoneCountry; national: string } | null {\n  const trimmed = input.trim();\n  let digits = onlyDigits(trimmed);\n  if (!trimmed.startsWith(\"+\")) { if (!trimmed.startsWith(\"00\")) return null; digits = digits.slice(2); }\n  for (const size of [3, 2, 1]) {\n    const dial = digits.slice(0, size);\n    const matches = pool.filter(entry => entry.dial === dial);\n    if (!matches.length) continue;\n    let rest = digits.slice(size);\n    const entry = matches.find(item => item.areaCodes?.some(code => rest.startsWith(code))) ?? matches.find(item => !item.areaCodes) ?? matches[0]!;\n    // \"+44 (0)7911…\" carries a trunk zero it should not; drop it when the rest is still a full number without it.\n    if (entry.trunk && rest.startsWith(entry.trunk) && rest.length - entry.trunk.length >= Math.min(...lengthsOf(entry))) rest = rest.slice(entry.trunk.length);\n    return { country: entry, national: capDigits(entry, rest) };\n  }\n  return null;\n}\n\n/** Formats an E.164 number for display, such as \"+44 7400 123456\". Returns the input when it cannot be read. */\nexport function formatPhoneNumber(e164: string) {\n  const parsed = parsePhoneNumber(e164);\n  return parsed ? `+${parsed.country.dial} ${formatNational(parsed.country, parsed.national)}` : e164;\n}\n\n/* ------------------------------------------------------------------------------------------------ */\n\nexport interface PhoneInputDetails {\n  country: PhoneCountry;\n  /** The number as shown in the field, without the calling code. */\n  formatted: string;\n  status: PhoneStatus;\n  valid: boolean;\n}\n\nexport interface PhoneInputProps {\n  label: string;\n  hideLabel?: boolean;\n  /** The number in E.164, such as \"+14155550132\". An empty string clears the field. */\n  value?: string;\n  defaultValue?: string;\n  /** Fires on every edit with the E.164 number (empty when there are no digits) and its parsed details. */\n  onValueChange?: (value: string, details: PhoneInputDetails) => void;\n  /** ISO code of the selected country. */\n  country?: string;\n  /** ISO code used until someone picks a country or enters an international number. */\n  defaultCountry?: string;\n  onCountryChange?: (iso: string) => void;\n  /** Limit the picker to these ISO codes. */\n  countries?: string[];\n  /** Pinned at the top of the picker under \"Suggested\". */\n  preferredCountries?: string[];\n  description?: string;\n  /** Replaces the built-in validation message. */\n  error?: string;\n  /** Show a message after blur when the number is incomplete. On by default. */\n  validate?: boolean;\n  disabled?: boolean;\n  required?: boolean;\n  /** Adds a hidden input carrying the E.164 value for native form submission. */\n  name?: string;\n  id?: string;\n  className?: string;\n  onBlur?: (event: ReactFocusEvent<HTMLInputElement>) => void;\n}\n\ntype Bezier = [number, number, number, number];\nconst enterEase = [...motionTokens.ease.enter] as Bezier;\nconst standardEase = [...motionTokens.ease.standard] as Bezier;\nconst { blur } = motionTokens;\n/** Duration springs restated as stiffness and damping, so a retarget mid flight keeps the velocity it already has. */\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GROW = physical(.4, 0), SHRINK = physical(.32, 0), GLIDE = physical(.28, 0), WIDTH = physical(.42, 0);\nconst PANEL_MAX = 340, CLOSED_RADIUS = 17, OPEN_RADIUS = 22, PAGE = 8;\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\n/** The flag and calling code roll in the direction of the list: a country further down rises from below. */\nconst layerVariants: Variants = {\n  enter: (dir: number) => ({ opacity: 0, y: `${dir * .6}em`, filter: `blur(${blur.soft}px)` }),\n  rest: { opacity: 1, y: 0, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" } },\n  exit: (dir: number) => ({ opacity: 0, y: `${dir * -.5}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .12, ease: standardEase } }),\n};\nconst layerFade: Variants = { enter: { opacity: 0 }, rest: { opacity: 1, y: 0, filter: \"none\" }, exit: { opacity: 0, transition: { duration: .08 } } };\n\nfunction matchesQuery(entry: PhoneCountry, needle: string) {\n  if (!needle) return true;\n  const digits = onlyDigits(needle);\n  if (digits && /^[+\\d\\s()-]+$/.test(needle)) return entry.dial.startsWith(digits) || digits.startsWith(entry.dial);\n  if (needle.replace(\"+\", \"\") === \"\") return true;\n  return entry.name.toLowerCase().includes(needle) || entry.iso.toLowerCase() === needle;\n}\n\n/** Position in the formatted text just after the nth digit. */\nfunction caretAfterDigits(text: string, count: number) {\n  if (count <= 0) { const first = text.search(/\\d/); return first < 0 ? text.length : Math.min(first, text.length); }\n  let seen = 0;\n  for (let index = 0; index < text.length; index++) if (/\\d/.test(text[index]!) && ++seen === count) return index + 1;\n  return text.length;\n}\n\nfunction MessageRow({ id, text, tone }: { id: string; text: string; tone: \"hint\" | \"error\" }) {\n  const reduced = useReducedFlag();\n  return <motion.span className={styles.messageSlot} initial={reduced ? false : { height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }}\n    exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }}\n    transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span key={text} id={id} className={tone === \"error\" ? styles.error : styles.hint} role={tone === \"error\" ? \"alert\" : undefined}\n        initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n        exit={{ opacity: 0, transition: { duration: .1 } }} transition={{ duration: reduced ? .12 : motionTokens.duration.standard, ease: enterEase }}>{text}</motion.span>\n    </AnimatePresence>\n  </motion.span>;\n}\n\ntype Row = { key: string; entry: PhoneCountry };\ntype Section = { key: string; label?: string; rows: Row[] };\n\n/**\n * A phone number field with a country picker. The country button grows into a searchable list, the number formats as it is\n * typed with a faint guide showing the rest of the expected shape, pasted or autofilled international numbers pick their own\n * country, and the value comes out in E.164. Typing \"+\" in the number jumps into the picker with a calling code search.\n */\nexport const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput({\n  label, hideLabel = false, value, defaultValue, onValueChange, country: countryProp, defaultCountry = \"US\", onCountryChange, countries,\n  preferredCountries = [\"US\", \"CA\", \"GB\"], description, error, validate = true, disabled = false, required, name, id, className, onBlur,\n}, forwardedRef) {\n  const reduced = useReducedFlag();\n  const uid = useId();\n  const inputId = id ?? `${uid}-number`;\n  const listId = `${uid}-list`;\n  const hintId = `${inputId}-hint`, errorId = `${inputId}-error`;\n  const optionId = (key: string) => `${uid}-opt-${key}`;\n\n  const pool = useMemo(() => countries?.length ? PHONE_COUNTRIES.filter(entry => countries.includes(entry.iso)) : PHONE_COUNTRIES, [countries]);\n  const fallback = BY_ISO.get(defaultCountry) ?? PHONE_COUNTRIES[0]!;\n\n  const [state, setState] = useState(() => {\n    const parsed = parsePhoneNumber(value ?? defaultValue ?? \"\", pool);\n    return parsed ? { iso: parsed.country.iso, digits: parsed.national } : { iso: fallback.iso, digits: \"\" };\n  });\n  const iso = countryProp ?? state.iso;\n  const current = BY_ISO.get(iso) ?? fallback;\n  const digits = state.digits;\n  const e164 = toE164(current, digits);\n\n  // A controlled value that differs from what the field last produced is read back in.\n  const [seenValue, setSeenValue] = useState(value);\n  if (value !== seenValue) {\n    setSeenValue(value);\n    if (value !== undefined && value !== e164) {\n      const parsed = parsePhoneNumber(value, pool);\n      setState(parsed ? { iso: parsed.country.iso, digits: parsed.national } : { iso, digits: \"\" });\n    }\n  }\n\n  const formatted = formatNational(current, digits);\n  const status = statusOf(current, digits);\n  const [touched, setTouched] = useState(false);\n  const lengths = lengthsOf(current);\n  const lengthCopy = lengths.length === 1 ? `${lengths[0]}` : `${lengths.slice(0, -1).join(\", \")} or ${lengths[lengths.length - 1]}`;\n  const builtIn = validate && touched && (status === \"incomplete\" || status === \"too-long\") ? `Numbers in ${current.name} have ${lengthCopy} digits` : undefined;\n  const message = error ?? builtIn;\n\n  /* The guide: the rest of the example number, in the shape this country expects, drawn faintly after what was typed. */\n  const guide = useMemo(() => {\n    const [trunk, rest] = splitTrunk(current, digits);\n    if (rest.length >= current.example.length) return \"\";\n    const full = formatNational(current, trunk + rest + current.example.slice(rest.length));\n    return full.startsWith(formatted) ? full.slice(formatted.length) : \"\";\n  }, [current, digits, formatted]);\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);\n  const pendingCaret = useRef<number | null>(null);\n  const [, rerender] = useReducer((count: number) => count + 1, 0);\n\n  const emit = useCallback((entry: PhoneCountry, nextDigits: string) => {\n    const nextStatus = statusOf(entry, nextDigits);\n    onValueChange?.(toE164(entry, nextDigits), { country: entry, formatted: formatNational(entry, nextDigits), status: nextStatus, valid: nextStatus === \"valid\" });\n  }, [onValueChange]);\n\n  const setNumber = (nextDigits: string, caretDigits: number | null, entry = current) => {\n    const capped = capDigits(entry, nextDigits);\n    pendingCaret.current = caretDigits === null ? null : Math.min(caretDigits, capped.length);\n    if (entry.iso !== current.iso) onCountryChange?.(entry.iso);\n    // Nothing changed (a refused digit): React restores the old text after this handler, so the caret is placed on the next render.\n    if (capped === digits && entry.iso === current.iso) { rerender(); return; }\n    setState({ iso: entry.iso, digits: capped });\n    emit(entry, capped);\n  };\n\n  function placeCaret() {\n    const input = inputRef.current, count = pendingCaret.current;\n    pendingCaret.current = null;\n    if (!input || count === null || document.activeElement !== input) return;\n    const position = caretAfterDigits(input.value, count);\n    input.setSelectionRange(position, position);\n  }\n  useLayoutEffect(placeCaret);\n\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  /* ---------------------------------------------- Number entry ---------------------------------------------- */\n\n  function onNumberChange(input: HTMLInputElement) {\n    const raw = input.value;\n    // Autofill and dropped text arrive as a change: an international number chooses its own country.\n    if (raw.includes(\"+\") || (/^\\s*00/.test(raw) && onlyDigits(raw).length > 6)) {\n      const parsed = parsePhoneNumber(raw.slice(Math.max(0, raw.indexOf(\"+\"))), pool);\n      if (parsed && parsed.national) {\n        if (parsed.country.iso !== current.iso) setAnnouncement(`Country set to ${parsed.country.name}`);\n        setNumber(parsed.national, parsed.national.length, parsed.country);\n        return;\n      }\n    }\n    const caret = input.selectionStart ?? raw.length;\n    setNumber(onlyDigits(raw), onlyDigits(raw.slice(0, caret)).length);\n  }\n\n  function onNumberKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {\n    const input = event.currentTarget;\n    if (event.key === \"+\" && !event.metaKey && !event.ctrlKey) { event.preventDefault(); openList(\"+\"); return; }\n    const start = input.selectionStart ?? 0, end = input.selectionEnd ?? 0;\n    if (start !== end || event.metaKey || event.ctrlKey || event.altKey) return;\n    // Deleting a separator deletes the digit beside it instead of doing nothing.\n    if (event.key === \"Backspace\" && start > 0 && !/\\d/.test(input.value[start - 1]!)) {\n      event.preventDefault();\n      const index = onlyDigits(input.value.slice(0, start)).length;\n      if (index > 0) setNumber(digits.slice(0, index - 1) + digits.slice(index), index - 1);\n    }\n    if (event.key === \"Delete\" && start < input.value.length && !/\\d/.test(input.value[start]!)) {\n      event.preventDefault();\n      const index = onlyDigits(input.value.slice(0, start)).length;\n      setNumber(digits.slice(0, index) + digits.slice(index + 1), index);\n    }\n  }\n\n  function onPaste(event: ReactClipboardEvent<HTMLInputElement>) {\n    const text = event.clipboardData.getData(\"text\");\n    if (!text) return;\n    event.preventDefault();\n    const input = event.currentTarget;\n    const parsed = /^\\s*(\\+|00)/.test(text) ? parsePhoneNumber(text, pool) : null;\n    if (parsed) {\n      if (parsed.country.iso !== current.iso) setAnnouncement(`Country set to ${parsed.country.name}`);\n      setNumber(parsed.national, parsed.national.length, parsed.country);\n      return;\n    }\n    let pasted = onlyDigits(text);\n    // \"1 415 555 0132\" pasted into a US field: the leading calling code is dropped when the number would not fit otherwise.\n    if (pasted.startsWith(current.dial) && pasted.length > capDigits(current, pasted).length) pasted = pasted.slice(current.dial.length);\n    // A whole number replaces what was there; a fragment goes in at the caret.\n    if (pasted.length >= Math.min(...lengthsOf(current))) { setNumber(pasted, pasted.length); return; }\n    const a = onlyDigits(input.value.slice(0, input.selectionStart ?? 0)).length;\n    const b = onlyDigits(input.value.slice(0, input.selectionEnd ?? 0)).length;\n    setNumber(digits.slice(0, a) + pasted + digits.slice(b), a + pasted.length);\n  }\n\n  /* ---------------------------------------------- Country picker ---------------------------------------------- */\n\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState<string | null>(null);\n  const [roll, setRoll] = useState(1);\n  const needle = query.trim().toLowerCase();\n\n  const sections = useMemo<Section[]>(() => {\n    const sorted = [...pool].sort((a, b) => a.name.localeCompare(b.name));\n    if (needle && needle !== \"+\") {\n      const hits = sorted.filter(entry => matchesQuery(entry, needle));\n      const digitsOnly = onlyDigits(needle);\n      if (digitsOnly) hits.sort((a, b) => Number(b.dial === digitsOnly) - Number(a.dial === digitsOnly) || a.dial.length - b.dial.length);\n      return [{ key: \"results\", rows: hits.map(entry => ({ key: `r-${entry.iso}`, entry })) }];\n    }\n    const preferred = preferredCountries.map(code => pool.find(entry => entry.iso === code)).filter((entry): entry is PhoneCountry => !!entry);\n    const all = { key: \"all\", label: preferred.length ? \"All countries\" : undefined, rows: sorted.map(entry => ({ key: `a-${entry.iso}`, entry })) };\n    return preferred.length ? [{ key: \"preferred\", label: \"Suggested\", rows: preferred.map(entry => ({ key: `p-${entry.iso}`, entry })) }, all] : [all];\n  }, [needle, pool, preferredCountries]);\n  const rows = useMemo(() => sections.flatMap(section => section.rows), [sections]);\n  const activeKey = active !== null && rows.some(row => row.key === active) ? active : rows[0]?.key ?? null;\n  const orderOf = useMemo(() => new Map([...pool].sort((a, b) => a.name.localeCompare(b.name)).map((entry, index) => [entry.iso, index])), [pool]);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const controlRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const listFaceRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const rowRefs = useRef(new Map<string, HTMLElement>());\n\n  /* One surface whose width, height, and corners spring between the country button and the open list. */\n  const anchorW = useMotionValue<number | string>(\"auto\");\n  const shapeW = useMotionValue<number | string>(\"100%\"), shapeH = useMotionValue<number | string>(\"100%\"), radius = useMotionValue(CLOSED_RADIUS);\n  const sizes = useRef({ trigger: 0, lid: 0, panel: 0, list: 0 });\n  const live = useRef({ open: false, reduced: false, measured: false });\n  useLayoutEffect(() => { live.current.reduced = reduced; }, [reduced]);\n\n  const place = useCallback((animated: boolean) => {\n    const { trigger, lid, panel, list } = sizes.current;\n    if (!trigger || !lid) return;\n    const isOpen = live.current.open;\n    const next = isOpen ? { w: Math.max(trigger, panel), h: lid + 2 + list, r: OPEN_RADIUS } : { w: trigger, h: lid, r: CLOSED_RADIUS };\n    if (!animated || live.current.reduced || !live.current.measured) {\n      anchorW.jump(trigger); shapeW.jump(next.w); shapeH.jump(next.h); radius.jump(next.r);\n      live.current.measured = true;\n      return;\n    }\n    const growing = isOpen;\n    animate(anchorW, trigger, WIDTH);\n    animate(shapeW, next.w, growing ? GROW : SHRINK);\n    animate(shapeH, next.h, growing ? GROW : SHRINK);\n    animate(radius, next.r, growing ? GROW : SHRINK);\n  }, [anchorW, radius, shapeH, shapeW]);\n\n  const read = useCallback(() => {\n    const measure = measureRef.current, control = controlRef.current, face = listFaceRef.current, root = rootRef.current;\n    if (!measure || !control || !face || !root) return false;\n    const panel = Math.min(PANEL_MAX, control.offsetWidth);\n    root.style.setProperty(\"--pi-panel-w\", `${panel}px`);\n    const previous = sizes.current;\n    const next = { trigger: measure.offsetWidth, lid: control.clientHeight, panel, list: face.offsetHeight };\n    sizes.current = next;\n    return next.trigger !== previous.trigger || next.lid !== previous.lid || next.panel !== previous.panel || (live.current.open && next.list !== previous.list);\n  }, []);\n\n  useLayoutEffect(() => {\n    const measure = measureRef.current, control = controlRef.current, face = listFaceRef.current;\n    if (read()) place(live.current.measured);\n    if (!measure || !control || !face || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => { if (read()) place(live.current.measured); });\n    observer.observe(measure); observer.observe(control); observer.observe(face);\n    return () => observer.disconnect();\n  }, [place, read]);\n\n  const pendingFocus = useRef<\"trigger\" | \"search\" | \"number\" | null>(null);\n  useLayoutEffect(() => {\n    if (live.current.open !== open) { live.current.open = open; read(); place(true); }\n    const target = pendingFocus.current;\n    pendingFocus.current = null;\n    if (target === \"search\") searchRef.current?.focus({ preventScroll: true });\n    if (target === \"trigger\") triggerRef.current?.focus({ preventScroll: true });\n    if (target === \"number\") { const input = inputRef.current; input?.focus({ preventScroll: true }); input?.setSelectionRange(input.value.length, input.value.length); }\n  }, [open, place, read]);\n\n  /* The highlight glides between rows on its own spring. */\n  const hy = useMotionValue(0), hh = useMotionValue(0), ho = useMotionValue(0);\n  const scrollIntent = useRef(false);\n  useLayoutEffect(() => {\n    const node = open && activeKey ? rowRefs.current.get(activeKey) : undefined;\n    if (!node) { animate(ho, 0, { duration: reduced || !open ? 0 : .12, ease: standardEase }); return; }\n    const top = node.offsetTop, height = node.offsetHeight;\n    if (ho.get() < .05 || reduced) { hy.jump(top); hh.jump(height); } else { animate(hy, top, GLIDE); animate(hh, height, GLIDE); }\n    animate(ho, 1, { duration: reduced ? 0 : .12, ease: enterEase });\n    const scroller = scrollRef.current;\n    if (scroller && scrollIntent.current) {\n      scrollIntent.current = false;\n      const pad = 6;\n      if (top < scroller.scrollTop + pad) scroller.scrollTop = top - pad;\n      else if (top + height > scroller.scrollTop + scroller.clientHeight - pad) scroller.scrollTop = top + height - scroller.clientHeight + pad;\n    }\n  }, [activeKey, hh, ho, hy, open, reduced, sections]);\n\n  function openList(seed = \"\") {\n    if (disabled || open) return;\n    setQuery(seed);\n    const selectedRow = seed ? null : sections.flatMap(section => section.rows).find(row => row.entry.iso === current.iso && !row.key.startsWith(\"p-\")) ?? null;\n    setActive(selectedRow?.key ?? null);\n    scrollIntent.current = true;\n    pendingFocus.current = \"search\";\n    setAnnouncement(\"\");\n    setOpen(true);\n  }\n  const close = useCallback((focus: \"trigger\" | \"number\" | null) => {\n    pendingFocus.current = focus;\n    setOpen(false);\n  }, [setOpen]);\n\n  function pick(entry: PhoneCountry | undefined) {\n    if (!entry) return;\n    if (entry.iso !== current.iso) {\n      setRoll(Math.sign((orderOf.get(entry.iso) ?? 0) - (orderOf.get(current.iso) ?? 0)) || 1);\n      const nextDigits = capDigits(entry, digits);\n      if (countryProp === undefined) setState({ iso: entry.iso, digits: nextDigits });\n      else setState(last => ({ ...last, digits: nextDigits }));\n      onCountryChange?.(entry.iso);\n      emit(entry, nextDigits);\n      setAnnouncement(`${entry.name}, +${entry.dial}`);\n    }\n    close(\"number\");\n  }\n\n  function move(key: string | null | undefined) {\n    if (!key) return;\n    scrollIntent.current = true;\n    setActive(key);\n  }\n\n  function onSearchKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {\n    const at = rows.findIndex(row => row.key === activeKey);\n    switch (event.key) {\n      case \"ArrowDown\": event.preventDefault(); move(rows[Math.min(rows.length - 1, at + 1)]?.key); return;\n      case \"ArrowUp\": event.preventDefault(); move(rows[Math.max(0, at - 1)]?.key); return;\n      case \"PageDown\": event.preventDefault(); move(rows[Math.min(rows.length - 1, at + PAGE)]?.key); return;\n      case \"PageUp\": event.preventDefault(); move(rows[Math.max(0, at - PAGE)]?.key); return;\n      case \"Home\": if (query) return; event.preventDefault(); move(rows[0]?.key); return;\n      case \"End\": if (query) return; event.preventDefault(); move(rows[rows.length - 1]?.key); return;\n      case \"Enter\": event.preventDefault(); pick(rows[at]?.entry); return;\n      case \"Escape\": event.preventDefault(); event.stopPropagation(); close(\"trigger\"); return;\n      case \"Tab\": close(null); return;\n    }\n  }\n\n  function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") { event.preventDefault(); openList(); return; }\n    if (event.key.length === 1 && event.key !== \" \" && !event.metaKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); openList(event.key); }\n  }\n\n  function onQuery(next: string) {\n    setQuery(next);\n    setActive(null);\n    if (scrollRef.current) scrollRef.current.scrollTop = 0;\n    const text = next.trim().toLowerCase();\n    const count = text && text !== \"+\" ? pool.filter(entry => matchesQuery(entry, text)).length : pool.length;\n    setAnnouncement(text ? count ? `${count} ${count === 1 ? \"country\" : \"countries\"}` : \"No matches\" : \"\");\n  }\n\n  useEffect(() => {\n    if (!open) return;\n    const down = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) close(null); };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [close, open]);\n\n  const onRootBlur = (event: ReactFocusEvent<HTMLDivElement>) => {\n    const next = event.relatedTarget as Node | null;\n    if (open && next && !event.currentTarget.contains(next)) close(null);\n  };\n\n  const showCheck = status === \"valid\";\n  const invalid = !!message;\n  const describedBy = [description ? hintId : null, message ? errorId : null].filter(Boolean).join(\" \") || undefined;\n  const rootStyle = { \"--pi-closed-r\": `${CLOSED_RADIUS}px` } as CSSProperties;\n\n  const face = (entry: PhoneCountry) => <>\n    <span className={styles.flag} aria-hidden=\"true\">{flagOf(entry.iso)}</span>\n    <span className={styles.dial}>+{entry.dial}</span>\n  </>;\n\n  return <div ref={rootRef} className={[styles.field, className].filter(Boolean).join(\" \")} style={rootStyle} data-open={open || undefined} data-disabled={disabled || undefined} onBlur={onRootBlur}>\n    <label htmlFor={inputId} className={hideLabel ? styles.srOnly : styles.label}>{label}</label>\n\n    <div ref={controlRef} className={styles.control} data-invalid={invalid || undefined}>\n      <motion.div className={styles.anchor} style={{ width: anchorW }}>\n        {/* Sizes the closed button: same padding and content, so the width spring has a target before anything moves. */}\n        <span ref={measureRef} className={`${styles.trigger} ${styles.measure}`} aria-hidden=\"true\">{face(current)}</span>\n\n        <motion.div className={styles.shape} style={{ width: shapeW, height: shapeH, borderRadius: radius }}>\n          <div className={styles.lid}>\n            <button ref={triggerRef} type=\"button\" className={styles.trigger} disabled={disabled} inert={open || undefined} tabIndex={open ? -1 : 0}\n              aria-haspopup=\"listbox\" aria-expanded={open} aria-controls={listId} aria-label={`Country, ${current.name} +${current.dial}`}\n              onClick={() => (open ? close(\"trigger\") : openList())} onKeyDown={onTriggerKeyDown}>\n              <span className={styles.slot} data-hidden={open || undefined}>\n                <AnimatePresence initial={false} custom={roll}>\n                  <motion.span key={current.iso} className={styles.layer} custom={roll} variants={reduced ? layerFade : layerVariants} initial=\"enter\" animate=\"rest\" exit=\"exit\"\n                    transition={reduced ? { duration: .12 } : { y: GLIDE, opacity: { duration: .2, ease: enterEase }, filter: { duration: .22, ease: enterEase } }}>\n                    {face(current)}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            </button>\n\n            <motion.div className={styles.searchRow} inert={!open || undefined} initial={false}\n              animate={open ? { opacity: 1, filter: \"blur(0px)\" } : { opacity: 0, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }}\n              transition={open ? { duration: .18, ease: enterEase, delay: reduced ? 0 : .05 } : { duration: .1, ease: standardEase }}>\n              <Search className={styles.searchIcon} size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n              <input ref={searchRef} className={styles.search} type=\"text\" role=\"combobox\" aria-label=\"Search countries or calling codes\" aria-expanded={open} aria-controls={listId}\n                aria-autocomplete=\"list\" aria-activedescendant={open && activeKey ? optionId(activeKey) : undefined} placeholder=\"Country or code\" value={query}\n                autoComplete=\"off\" spellCheck={false} onChange={event => onQuery(event.target.value)} onKeyDown={onSearchKeyDown} />\n              <AnimatePresence initial={false}>\n                {query && <motion.button key=\"clear\" type=\"button\" className={styles.clear} aria-label=\"Clear search\" onPointerDown={event => event.preventDefault()}\n                  onClick={() => { onQuery(\"\"); searchRef.current?.focus(); }}\n                  initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: .1 } }} transition={reduced ? { duration: 0 } : GLIDE}>\n                  <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                </motion.button>}\n              </AnimatePresence>\n              <button type=\"button\" className={styles.closeHit} aria-label=\"Close country list\" onClick={() => close(\"trigger\")} />\n            </motion.div>\n\n            <motion.span className={styles.chevron} aria-hidden=\"true\" initial={false} animate={{ rotate: open ? 180 : 0 }} transition={reduced ? { duration: 0 } : GLIDE}>\n              <ChevronDown size={16} strokeWidth={1.75} />\n            </motion.span>\n          </div>\n\n          <motion.div ref={listFaceRef} className={styles.listFace} inert={!open || undefined} aria-hidden={!open || undefined} initial={false}\n            animate={open ? { opacity: 1, y: 0, filter: \"blur(0px)\" } : { opacity: 0, y: reduced ? 0 : -6, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }}\n            transition={open ? { y: GROW, opacity: { duration: .2, ease: enterEase, delay: reduced ? 0 : .04 }, filter: { duration: .22, ease: enterEase, delay: .04 } } : { duration: .1, ease: standardEase }}>\n            <div ref={scrollRef} className={styles.scroll}>\n              <div id={listId} className={styles.options} role=\"listbox\" aria-label=\"Countries\">\n                <motion.span className={styles.highlight} style={{ y: hy, height: hh, opacity: ho }} aria-hidden=\"true\" />\n                {sections.map(section => {\n                  const body = section.rows.map(row => {\n                    const selected = row.entry.iso === current.iso;\n                    return <div key={row.key} id={optionId(row.key)} role=\"option\" aria-selected={selected} className={styles.option} data-active={row.key === activeKey || undefined}\n                      ref={node => { if (node) rowRefs.current.set(row.key, node); else rowRefs.current.delete(row.key); }}\n                      onPointerMove={event => { if (event.pointerType === \"mouse\" && row.key !== activeKey) setActive(row.key); }}\n                      onPointerDown={event => event.preventDefault()} onClick={() => pick(row.entry)}>\n                      <span className={styles.flag} aria-hidden=\"true\">{flagOf(row.entry.iso)}</span>\n                      <span className={styles.name}>{row.entry.name}</span>\n                      <span className={styles.meta}>+{row.entry.dial}</span>\n                      <span className={styles.check} data-on={selected || undefined} aria-hidden=\"true\"><Check size={16} strokeWidth={1.75} /></span>\n                    </div>;\n                  });\n                  return section.label\n                    ? <div key={section.key} role=\"group\" aria-labelledby={`${uid}-${section.key}`} className={styles.group}>\n                      <div id={`${uid}-${section.key}`} className={styles.groupLabel} role=\"presentation\">{section.label}</div>{body}\n                    </div>\n                    : <div key={section.key} role=\"presentation\" className={styles.group}>{body}</div>;\n                })}\n                {rows.length === 0 && <p className={styles.empty}>No countries match “{query.trim()}”</p>}\n              </div>\n            </div>\n          </motion.div>\n        </motion.div>\n      </motion.div>\n\n      <div className={styles.numberWrap}>\n        <span className={styles.guide} aria-hidden=\"true\"><span className={styles.guideTyped}>{formatted}</span>{guide}</span>\n        <input ref={inputRef} id={inputId} className={styles.number} type=\"tel\" inputMode=\"tel\" autoComplete=\"tel\" value={formatted} disabled={disabled} required={required}\n          aria-invalid={invalid || undefined} aria-describedby={describedBy} aria-label={hideLabel ? label : undefined}\n          onChange={event => onNumberChange(event.currentTarget)} onKeyDown={onNumberKeyDown} onPaste={onPaste}\n          onBlur={event => { setTouched(digits.length > 0); onBlur?.(event); }} />\n        <AnimatePresence initial={false}>\n          {showCheck && <motion.span key=\"ok\" className={styles.valid} aria-hidden=\"true\"\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${blur.subtle}px)` }} animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, scale: reduced ? 1 : .8, transition: { duration: .1 } }} transition={reduced ? { duration: .12 } : motionTokens.spring.snappy}>\n            <Check size={16} strokeWidth={2} />\n          </motion.span>}\n        </AnimatePresence>\n      </div>\n    </div>\n\n    <AnimatePresence initial={false}>{description && !message && <MessageRow key=\"hint\" id={hintId} text={description} tone=\"hint\" />}</AnimatePresence>\n    <AnimatePresence initial={false}>{message && <MessageRow key=\"error\" id={errorId} text={message} tone=\"error\" />}</AnimatePresence>\n    {name && <input type=\"hidden\" name={name} value={e164} />}\n    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement || (showCheck ? `Valid ${current.name} number` : \"\")}</span>\n  </div>;\n});\n\nPhoneInput.displayName = \"PhoneInput\";\n\nexport default PhoneInput;\n"
    },
    {
      "path": "registry/components/phone-input/phone-input.module.css",
      "type": "registry:component",
      "target": "@components/xzero/phone-input/phone-input.module.css",
      "content": "/* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */\n.field { --pi-h: var(--control-height-md); --pi-panel-w: 320px; position: relative; display: grid; min-width: 0; font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n.field[data-open] { z-index: 30; }\n.label { justify-self: start; margin-bottom: var(--space-2); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n\n/* The shell matches the labeled input: one border, one radius, and a quiet border change on hover and focus. */\n.control { position: relative; display: flex; min-width: 0; height: var(--pi-h); box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .control:hover:not([data-invalid]) { border-color: var(--foreground); } }\n.control:has(.number:focus) { border-color: var(--foreground); }\n.control[data-invalid] { border-color: var(--danger); }\n.field[data-disabled] .control { opacity: .5; background: var(--surface-muted); }\n\n/* The anchor holds the closed button's place and springs to a new width when the calling code changes length. */\n.anchor { position: relative; flex: none; height: 100%; }\n.anchor::after { content: \"\"; position: absolute; top: 10px; right: 0; bottom: 10px; width: 1px; background: var(--border); transition: opacity var(--duration-fast) var(--ease-standard); }\n.field[data-open] .anchor::after { opacity: 0; }\n\n/* One material for both states. Open, it steps out over the shell's own border so the two edges never double up. */\n.shape { position: absolute; z-index: 2; top: 0; left: 0; overflow: clip; background: var(--surface); transition: box-shadow var(--duration-considered) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.shape::after { content: \"\"; position: absolute; inset: 0; z-index: 3; border: 1px solid var(--border); border-radius: inherit; opacity: 0; pointer-events: none; transition: opacity var(--duration-fast) var(--ease-standard); }\n.field[data-open] .shape { translate: -1px -1px; background: var(--surface-raised); box-shadow: var(--shadow-floating); }\n.field[data-open] .shape::after { opacity: 1; }\n.field[data-disabled] .shape { background: transparent; }\n\n.lid { position: absolute; z-index: 1; top: 0; right: 0; left: 0; height: calc(var(--pi-h) - 2px); }\n\n.trigger { box-sizing: border-box; display: flex; height: calc(var(--pi-h) - 2px); align-items: center; padding: 0 36px 0 12px; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; }\n.lid .trigger { position: absolute; inset: 3px auto 3px 3px; width: calc(100% - 6px); height: auto; margin: 0; padding: 0 33px 0 9px; border: 0; border-radius: calc(var(--radius-control) - 4px); background: transparent; color: var(--foreground); cursor: pointer; font-family: inherit; letter-spacing: inherit; text-align: left; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .field:not([data-open], [data-disabled]) .lid .trigger:hover { background: var(--surface-muted); } }\n.lid .trigger:active:not(:disabled) { background: var(--surface-muted); }\n.lid .trigger:disabled { cursor: not-allowed; }\n/* No focus rings (product decision): keyboard focus reads as the same quiet fill as hover. */\n.lid .trigger:focus-visible { background: var(--surface-muted); }\n.clear:focus-visible, .closeHit:focus-visible { background: color-mix(in oklab, var(--foreground) 8%, transparent); }\n/* An invisible copy that says how wide the closed button wants to be. */\n.measure { visibility: hidden; pointer-events: none; width: max-content; }\n\n.slot { position: relative; display: block; min-width: 0; height: 100%; flex: 1; }\n.slot[data-hidden] { opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); }\n.layer { position: absolute; inset: 0; display: flex; align-items: center; gap: 6px; }\n.flag { display: inline-grid; width: 20px; flex: none; place-items: center; font-family: \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Noto Color Emoji\", var(--font-body); font-size: 17px; font-weight: 400; line-height: 1; }\n.dial { font-variant-numeric: tabular-nums; }\n\n.chevron { position: absolute; top: 50%; right: 12px; display: grid; width: 16px; height: 16px; margin-top: -8px; place-items: center; color: var(--text-muted); pointer-events: none; }\n\n/* Search takes the lid's place while the list is open. */\n.searchRow { position: absolute; inset: 0; display: flex; align-items: center; gap: var(--space-2); padding: 0 2px 0 13px; }\n.searchRow[inert] { pointer-events: none; }\n.searchIcon { flex: none; color: var(--text-muted); }\n.search { min-width: 0; height: 100%; flex: 1; padding: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); outline: none; }\n.search::placeholder { color: var(--text-muted); }\n.clear { display: grid; width: 24px; height: 24px; flex: none; place-items: center; padding: 0; border: 0; border-radius: var(--radius-pill); background: color-mix(in oklab, var(--foreground) 8%, transparent); color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; }\n/* Sits over the shared chevron, so the chevron closes the list. */\n.closeHit { width: 36px; height: 36px; flex: none; padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) {\n  .closeHit:hover, .clear:hover { background: color-mix(in oklab, var(--foreground) 8%, transparent); }\n  .clear:hover { color: var(--foreground); }\n}\n\n/* The list hangs under the lid at its final width, so rows never reflow while the surface springs open. */\n.listFace { position: absolute; top: calc(var(--pi-h) - 2px); left: 0; box-sizing: border-box; width: var(--pi-panel-w); padding: 0 6px 6px; }\n.listFace::before { content: \"\"; position: absolute; top: 0; right: 14px; left: 14px; height: 1px; background: var(--border-subtle); }\n.listFace[inert] { pointer-events: none; }\n.scroll { max-height: 280px; overflow-y: auto; overscroll-behavior: contain; padding-top: 6px; scrollbar-width: thin; }\n.options { position: relative; display: grid; gap: 2px; }\n.highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: 16px; background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }\n.group { display: grid; gap: 2px; }\n.group + .group { margin-top: 4px; }\n.groupLabel { padding: 8px 10px 4px; color: var(--text-muted); font-size: var(--text-xs); }\n.option { position: relative; display: flex; min-height: 36px; box-sizing: border-box; align-items: center; gap: 10px; padding: 0 10px; border-radius: 16px; color: var(--text-secondary); cursor: pointer; font-size: var(--text-sm); user-select: none; -webkit-user-select: none; transition: color var(--duration-fast) var(--ease-standard); }\n.option[data-active], .option[aria-selected=\"true\"] { color: var(--foreground); }\n.name { min-width: 0; flex: 1; overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.meta { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.check { display: grid; width: 16px; height: 16px; flex: none; place-items: center; color: var(--foreground); opacity: 0; transform: scale(.6); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.check[data-on] { opacity: 1; transform: none; }\n.empty { margin: 0; padding: 12px 10px 14px; color: var(--text-muted); font-size: var(--text-sm); }\n\n/* The number and its guide share one box. The guide paints the typed part invisibly, then the rest of the example faintly after it. */\n.numberWrap { position: relative; display: flex; min-width: 0; flex: 1; align-items: center; }\n.number, .guide { box-sizing: border-box; padding: 0 36px 0 12px; font-family: inherit; font-size: var(--text-sm); font-variant-numeric: tabular-nums; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }\n.number { position: relative; z-index: 1; width: 100%; height: 100%; min-width: 0; margin: 0; border: 0; border-radius: 0 var(--radius-control) var(--radius-control) 0; background: transparent; color: var(--foreground); outline: none; }\n.number:disabled { cursor: not-allowed; }\n.guide { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: color-mix(in oklab, var(--text-muted) 72%, transparent); pointer-events: none; white-space: pre; }\n.guideTyped { visibility: hidden; }\n.valid { position: absolute; z-index: 1; top: 50%; right: 12px; display: grid; width: 18px; height: 18px; margin-top: -9px; place-items: center; color: var(--success); pointer-events: none; }\n\n/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */\n.messageSlot { position: relative; display: block; overflow: hidden; }\n.hint, .error { display: block; padding-top: var(--space-2); font-size: var(--text-xs); line-height: var(--leading-body); }\n.hint { color: var(--text-muted); }\n.error { color: var(--danger); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .shape, .shape::after, .check, .slot, .control { transition-duration: 0ms; }\n}\n@media (prefers-contrast: more) { .shape::after { border-color: var(--border-strong); } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/phone-input",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/phone-input",
    "markdown": "https://ui.xzero.co.uk/components/phone-input/markdown"
  }
}