{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A CountryDialog component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { Check, Search, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\n\nconst ROW_HEIGHT = 40;\nconst LIST_MAX_HEIGHT = 288;\nconst OVERSCAN_ROWS = 4;\nconst VIRTUALIZE_THRESHOLD = 50;\nconst FOCUS_DELAY_MS = 100;\nconst REGIONAL_INDICATOR_OFFSET = 0x1_f1_e6;\nconst CHAR_CODE_A = \"A\".charCodeAt(0);\nconst DIACRITIC_REGEX = /[̀-ͯ]/g;\nconst ISO_CODE_REGEX = /^[A-Za-z]{2}$/;\n\nexport interface Country {\n  code: string;\n  dialCode?: string;\n  flag?: string;\n  group?: string;\n  name: string;\n}\n\nexport interface CountryDialogProps {\n  className?: string;\n  countries: Country[];\n  emptyMessage?: string;\n  groupBy?: boolean;\n  onOpenChange: (open: boolean) => void;\n  onValueChange: (code: string) => void;\n  open: boolean;\n  placeholder?: string;\n  recent?: string[];\n  trigger?: ReactNode;\n  value?: string;\n}\n\ntype Row =\n  | { id: string; kind: \"header\"; label: string }\n  | { country: Country; id: string; kind: \"option\" };\n\nconst normalize = (input: string) =>\n  input.normalize(\"NFD\").replace(DIACRITIC_REGEX, \"\").toLowerCase();\n\nconst matchesQuery = (country: Country, query: string) => {\n  const normalizedQuery = normalize(query);\n  return (\n    normalize(country.name).includes(normalizedQuery) ||\n    normalize(country.code).includes(normalizedQuery) ||\n    (country.dialCode\n      ? normalize(country.dialCode).includes(normalizedQuery)\n      : false)\n  );\n};\n\nconst getFlagEmoji = (code: string): string | null => {\n  if (!ISO_CODE_REGEX.test(code)) {\n    return null;\n  }\n  const upper = code.toUpperCase();\n  const first = REGIONAL_INDICATOR_OFFSET + (upper.charCodeAt(0) - CHAR_CODE_A);\n  const second =\n    REGIONAL_INDICATOR_OFFSET + (upper.charCodeAt(1) - CHAR_CODE_A);\n  return String.fromCodePoint(first, second);\n};\n\nconst buildRows = (\n  countries: Country[],\n  query: string,\n  recent: string[] | undefined,\n  groupBy: boolean\n): Row[] => {\n  const trimmedQuery = query.trim();\n  const filtered = trimmedQuery\n    ? countries.filter((country) => matchesQuery(country, trimmedQuery))\n    : countries;\n\n  const rows: Row[] = [];\n\n  if (!trimmedQuery && recent && recent.length > 0) {\n    const recentCountries = recent\n      .map((code) => countries.find((country) => country.code === code))\n      .filter((country): country is Country => Boolean(country));\n\n    if (recentCountries.length > 0) {\n      rows.push({ id: \"header-recent\", kind: \"header\", label: \"Recent\" });\n      for (const country of recentCountries) {\n        rows.push({\n          country,\n          id: `option-recent-${country.code}`,\n          kind: \"option\",\n        });\n      }\n    }\n  }\n\n  if (groupBy) {\n    const groups = new Map<string, Country[]>();\n    for (const country of filtered) {\n      const groupLabel = country.group ?? \"Other\";\n      const existing = groups.get(groupLabel);\n      if (existing) {\n        existing.push(country);\n      } else {\n        groups.set(groupLabel, [country]);\n      }\n    }\n\n    for (const [groupLabel, groupCountries] of groups) {\n      rows.push({\n        id: `header-group-${groupLabel}`,\n        kind: \"header\",\n        label: groupLabel,\n      });\n      for (const country of groupCountries) {\n        rows.push({\n          country,\n          id: `option-group-${groupLabel}-${country.code}`,\n          kind: \"option\",\n        });\n      }\n    }\n\n    return rows;\n  }\n\n  if (rows.length > 0) {\n    rows.push({ id: \"header-all\", kind: \"header\", label: \"All countries\" });\n  }\n  for (const country of filtered) {\n    rows.push({ country, id: `option-all-${country.code}`, kind: \"option\" });\n  }\n\n  return rows;\n};\n\nconst CountryOption = ({\n  country,\n  highlighted,\n  id,\n  onSelect,\n  selected,\n}: {\n  country: Country;\n  highlighted: boolean;\n  id: string;\n  onSelect: () => void;\n  selected: boolean;\n}) => {\n  const emoji = country.flag ?? getFlagEmoji(country.code);\n\n  return (\n    // biome-ignore lint/a11y/useFocusableInteractive: DOM focus intentionally stays on the search input (role=\"combobox\" + aria-activedescendant above), not on this option, per the WAI-ARIA APG \"editable combobox with list autocomplete\" pattern.\n    <div\n      aria-selected={selected}\n      className={cn(\n        \"flex min-h-[40px] cursor-pointer items-center gap-3 px-3 text-sm transition-colors\",\n        highlighted && \"bg-muted\",\n        selected && \"font-medium text-brand\"\n      )}\n      id={id}\n      onClick={onSelect}\n      onMouseDown={(event) => event.preventDefault()}\n      role=\"option\"\n    >\n      <span aria-hidden=\"true\" className=\"w-6 shrink-0 text-center text-base\">\n        {emoji ?? country.code}\n      </span>\n      <span className=\"min-w-0 flex-1 truncate\">{country.name}</span>\n      {country.dialCode ? (\n        <span className=\"shrink-0 text-muted-foreground text-xs\">\n          {country.dialCode}\n        </span>\n      ) : null}\n      {selected ? (\n        <Check aria-hidden=\"true\" className=\"h-4 w-4 shrink-0 text-brand\" />\n      ) : null}\n    </div>\n  );\n};\n\nconst CountryDialog = ({\n  countries,\n  value,\n  onValueChange,\n  open,\n  onOpenChange,\n  trigger,\n  placeholder = \"Search countries...\",\n  recent,\n  groupBy = false,\n  emptyMessage = \"No countries found\",\n  className = \"\",\n}: CountryDialogProps) => {\n  const [query, setQuery] = useState(\"\");\n  const [highlightedIndex, setHighlightedIndex] = useState(0);\n  const [scrollTop, setScrollTop] = useState(0);\n  const [mounted, setMounted] = useState(false);\n\n  const dialogRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const searchInputRef = useRef<HTMLInputElement>(null);\n  const previousActiveElementRef = useRef<HTMLElement | null>(null);\n\n  const listboxId = useId();\n  const shouldReduceMotion = useReducedMotion();\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  // Focus the search field on open, restore focus to whatever opened the dialog on close.\n  useEffect(() => {\n    if (open) {\n      previousActiveElementRef.current = document.activeElement as HTMLElement;\n      const timeoutId = setTimeout(() => {\n        searchInputRef.current?.focus();\n      }, FOCUS_DELAY_MS);\n      return () => clearTimeout(timeoutId);\n    }\n    previousActiveElementRef.current?.focus();\n  }, [open]);\n\n  // Escape closes the dialog; Tab is trapped within it while open.\n  useEffect(() => {\n    if (!open) {\n      return;\n    }\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        onOpenChange(false);\n        return;\n      }\n\n      if (event.key === \"Tab\" && dialogRef.current) {\n        const focusableElements = Array.from(\n          dialogRef.current.querySelectorAll<HTMLElement>(\n            'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n          )\n        );\n        const [firstElement] = focusableElements;\n        const lastElement = focusableElements.at(-1);\n\n        if (event.shiftKey) {\n          if (document.activeElement === firstElement) {\n            event.preventDefault();\n            lastElement?.focus();\n          }\n        } else if (document.activeElement === lastElement) {\n          event.preventDefault();\n          firstElement?.focus();\n        }\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, [open, onOpenChange]);\n\n  const rows = useMemo(\n    () => buildRows(countries, query, recent, groupBy),\n    [countries, query, recent, groupBy]\n  );\n\n  const optionRows = useMemo(\n    () =>\n      rows.filter(\n        (row): row is Row & { kind: \"option\" } => row.kind === \"option\"\n      ),\n    [rows]\n  );\n\n  const clampedHighlightedIndex =\n    optionRows.length === 0\n      ? -1\n      : Math.min(highlightedIndex, optionRows.length - 1);\n  const activeId =\n    clampedHighlightedIndex >= 0\n      ? optionRows[clampedHighlightedIndex]?.id\n      : undefined;\n\n  // Keep the highlighted row scrolled into view, including inside the virtualized window.\n  useEffect(() => {\n    const container = listRef.current;\n    const highlighted = optionRows[clampedHighlightedIndex];\n    if (!(container && highlighted)) {\n      return;\n    }\n    const rowIndex = rows.findIndex((row) => row.id === highlighted.id);\n    if (rowIndex === -1) {\n      return;\n    }\n    const rowTop = rowIndex * ROW_HEIGHT;\n    const rowBottom = rowTop + ROW_HEIGHT;\n    if (rowTop < container.scrollTop) {\n      container.scrollTop = rowTop;\n    } else if (rowBottom > container.scrollTop + container.clientHeight) {\n      container.scrollTop = rowBottom - container.clientHeight;\n    }\n  }, [clampedHighlightedIndex, optionRows, rows]);\n\n  const handleSelect = (country: Country) => {\n    onValueChange(country.code);\n    onOpenChange(false);\n  };\n\n  const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {\n    if (optionRows.length === 0) {\n      return;\n    }\n\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      setHighlightedIndex((index) =>\n        Math.min(index + 1, optionRows.length - 1)\n      );\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      setHighlightedIndex((index) => Math.max(index - 1, 0));\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      setHighlightedIndex(0);\n    } else if (event.key === \"End\") {\n      event.preventDefault();\n      setHighlightedIndex(optionRows.length - 1);\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      const target = optionRows[clampedHighlightedIndex];\n      if (target) {\n        handleSelect(target.country);\n      }\n    }\n  };\n\n  const handleQueryChange = (nextQuery: string) => {\n    setQuery(nextQuery);\n    setHighlightedIndex(0);\n  };\n\n  const handleClearSearch = () => {\n    handleQueryChange(\"\");\n    searchInputRef.current?.focus();\n  };\n\n  // Windowing is skipped for grouped results: section headers make slice-window\n  // math for a flat list impractical, and grouped lists are expected to be shorter.\n  const shouldVirtualize = !groupBy && rows.length > VIRTUALIZE_THRESHOLD;\n  const visibleRowCount =\n    Math.ceil(LIST_MAX_HEIGHT / ROW_HEIGHT) + OVERSCAN_ROWS * 2;\n  const startIndex = shouldVirtualize\n    ? Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN_ROWS)\n    : 0;\n  const endIndex = shouldVirtualize\n    ? Math.min(rows.length, startIndex + visibleRowCount)\n    : rows.length;\n  const visibleRows = rows.slice(startIndex, endIndex);\n  const topPadding = shouldVirtualize ? startIndex * ROW_HEIGHT : 0;\n  const bottomPadding = shouldVirtualize\n    ? (rows.length - endIndex) * ROW_HEIGHT\n    : 0;\n\n  const dialogContent = (\n    <AnimatePresence>\n      {open ? (\n        <>\n          <motion.div\n            animate={{ opacity: 1 }}\n            className=\"fixed inset-0 z-[80] bg-background/70 backdrop-blur-sm\"\n            exit={{ opacity: 0 }}\n            initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }}\n            onClick={() => onOpenChange(false)}\n            transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}\n          />\n          <motion.div\n            animate={{ opacity: 1 }}\n            className=\"fixed inset-0 z-[90] flex items-start justify-center overflow-y-auto px-4 py-16 sm:p-16\"\n            exit={{ opacity: 0 }}\n            initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }}\n            transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}\n          >\n            <motion.div\n              animate={\n                shouldReduceMotion\n                  ? { opacity: 1 }\n                  : { opacity: 1, scale: 1, y: 0 }\n              }\n              aria-label=\"Select a country\"\n              aria-modal=\"true\"\n              className=\"relative w-full max-w-sm overflow-hidden rounded-xl border bg-primary shadow-xl\"\n              exit={\n                shouldReduceMotion\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : {\n                      opacity: 0,\n                      scale: 0.97,\n                      transition: { duration: 0.15 },\n                      y: 8,\n                    }\n              }\n              initial={\n                shouldReduceMotion\n                  ? { opacity: 1 }\n                  : { opacity: 0, scale: 0.97, y: 8 }\n              }\n              ref={dialogRef}\n              role=\"dialog\"\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : {\n                      damping: 25,\n                      duration: 0.25,\n                      stiffness: 300,\n                      type: \"spring\" as const,\n                    }\n              }\n            >\n              <div className=\"flex items-center gap-2 border-b p-3\">\n                <Search\n                  aria-hidden=\"true\"\n                  className=\"h-4 w-4 shrink-0 text-muted-foreground\"\n                />\n                <input\n                  aria-activedescendant={activeId}\n                  aria-autocomplete=\"list\"\n                  aria-controls={listboxId}\n                  aria-expanded={open}\n                  aria-label=\"Search countries\"\n                  className=\"w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n                  onChange={(event) => handleQueryChange(event.target.value)}\n                  onKeyDown={handleInputKeyDown}\n                  placeholder={placeholder}\n                  ref={searchInputRef}\n                  role=\"combobox\"\n                  type=\"text\"\n                  value={query}\n                />\n                {query ? (\n                  <SmoothButton\n                    aria-label=\"Clear search\"\n                    // Subordinate to the close control beside it — same hit\n                    // area, visibly lighter glyph, so two X's never read as peers.\n                    className=\"shrink-0 text-muted-foreground hover:text-foreground [&_svg]:size-3.5\"\n                    onClick={handleClearSearch}\n                    shape=\"pill\"\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                  >\n                    <X aria-hidden=\"true\" />\n                  </SmoothButton>\n                ) : null}\n                <SmoothButton\n                  aria-label=\"Close dialog\"\n                  className=\"shrink-0 text-muted-foreground hover:text-foreground\"\n                  onClick={() => onOpenChange(false)}\n                  shape=\"pill\"\n                  size=\"icon-sm\"\n                  variant=\"ghost\"\n                >\n                  <X aria-hidden=\"true\" />\n                </SmoothButton>\n              </div>\n\n              <div\n                aria-label=\"Countries\"\n                className=\"overflow-y-auto py-1\"\n                id={listboxId}\n                onScroll={(event) =>\n                  setScrollTop(event.currentTarget.scrollTop)\n                }\n                ref={listRef}\n                role=\"listbox\"\n                style={{ maxHeight: LIST_MAX_HEIGHT }}\n              >\n                {optionRows.length === 0 ? (\n                  <p className=\"px-4 py-8 text-center text-muted-foreground text-sm\">\n                    {emptyMessage}\n                  </p>\n                ) : (\n                  <>\n                    {topPadding > 0 ? (\n                      <div aria-hidden=\"true\" style={{ height: topPadding }} />\n                    ) : null}\n                    {visibleRows.map((row) =>\n                      row.kind === \"header\" ? (\n                        <div\n                          className=\"px-3 pt-3 pb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide\"\n                          key={row.id}\n                          role=\"presentation\"\n                        >\n                          {row.label}\n                        </div>\n                      ) : (\n                        <CountryOption\n                          country={row.country}\n                          highlighted={row.id === activeId}\n                          id={row.id}\n                          key={row.id}\n                          onSelect={() => handleSelect(row.country)}\n                          selected={value === row.country.code}\n                        />\n                      )\n                    )}\n                    {bottomPadding > 0 ? (\n                      <div\n                        aria-hidden=\"true\"\n                        style={{ height: bottomPadding }}\n                      />\n                    ) : null}\n                  </>\n                )}\n              </div>\n            </motion.div>\n          </motion.div>\n        </>\n      ) : null}\n    </AnimatePresence>\n  );\n\n  return (\n    <>\n      <SmoothButton\n        aria-expanded={open}\n        aria-haspopup=\"dialog\"\n        className={className}\n        onClick={() => onOpenChange(true)}\n        variant=\"outline\"\n      >\n        {trigger ?? \"Select country\"}\n      </SmoothButton>\n      {mounted ? createPortal(dialogContent, document.body) : null}\n    </>\n  );\n};\n\nexport default CountryDialog;\n","path":"index.tsx","target":"components/smoothui/country-dialog/index.tsx","type":"registry:ui"}],"name":"country-dialog","registryDependencies":["https://smoothui.dev/r/smooth-button.json","https://smoothui.dev/r/tokens.json"],"title":"Country Dialog","type":"registry:ui"}