{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "places-autocomplete",
  "title": "Places Autocomplete",
  "description": "Address autocomplete using Google Places API (New) with shadcn Input and accessible suggestion list.",
  "dependencies": [
    "@types/google.maps"
  ],
  "registryDependencies": [
    "input",
    "button"
  ],
  "files": [
    {
      "path": "registry/new-york/places-autocomplete/places-autocomplete.tsx",
      "content": "\"use client\"\n\nimport { MapPin } from \"lucide-react\"\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\n\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\n\nimport { useGooglePlacesScript } from \"./hooks/use-google-places-script\"\n\nconst DEFAULT_DEBOUNCE_MS = 300\n\nexport type SelectedPlace = {\n  address: string\n  lat: number | null\n  lng: number | null\n  placeId: string | null\n}\n\ntype AddressSuggestion = {\n  id: string\n  label: string\n  prediction: google.maps.places.PlacePrediction\n}\n\nexport type PlacesAutocompleteProps = {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  onPlaceSelect: (place: SelectedPlace) => void\n  apiKey?: string\n  countryCode?: string | null\n  debounceMs?: number\n  placeholder?: string\n  disabled?: boolean\n  className?: string\n  inputClassName?: string\n  showPoweredByGoogle?: boolean\n}\n\nexport function PlacesAutocomplete({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onPlaceSelect,\n  apiKey,\n  countryCode,\n  debounceMs = DEFAULT_DEBOUNCE_MS,\n  placeholder = \"Start typing an address\",\n  disabled = false,\n  className,\n  inputClassName,\n  showPoweredByGoogle = true,\n}: PlacesAutocompleteProps) {\n  const listboxId = useId()\n  const inputRef = useRef<HTMLInputElement>(null)\n  const requestIdRef = useRef(0)\n  const debounceTimeoutRef = useRef<number | null>(null)\n  const sessionTokenRef =\n    useRef<google.maps.places.AutocompleteSessionToken | null>(null)\n\n  const isControlled = value !== undefined\n  const [internalValue, setInternalValue] = useState(defaultValue)\n  const inputValue = isControlled ? value : internalValue\n\n  const { isLoaded, error, hasApiKey, GoogleMapsScript } = useGooglePlacesScript({\n    apiKey,\n  })\n\n  const [open, setOpen] = useState(false)\n  const [loadingSuggestions, setLoadingSuggestions] = useState(false)\n  const [selectingId, setSelectingId] = useState<string | null>(null)\n  const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])\n  const [activeIndex, setActiveIndex] = useState(-1)\n  const [dropdownRect, setDropdownRect] = useState<{\n    top: number\n    left: number\n    width: number\n  } | null>(null)\n  const [mounted, setMounted] = useState(false)\n\n  useEffect(() => {\n    setMounted(true)\n  }, [])\n\n  const updateDropdownRect = useCallback(() => {\n    const input = inputRef.current\n    if (!input) {\n      return\n    }\n\n    const rect = input.getBoundingClientRect()\n    setDropdownRect({\n      top: rect.bottom + 6,\n      left: rect.left,\n      width: rect.width,\n    })\n  }, [])\n\n  useLayoutEffect(() => {\n    if (!open) {\n      setDropdownRect(null)\n      return\n    }\n\n    updateDropdownRect()\n\n    window.addEventListener(\"resize\", updateDropdownRect)\n    window.addEventListener(\"scroll\", updateDropdownRect, true)\n\n    return () => {\n      window.removeEventListener(\"resize\", updateDropdownRect)\n      window.removeEventListener(\"scroll\", updateDropdownRect, true)\n    }\n  }, [open, suggestions.length, updateDropdownRect])\n\n  const setInputValue = useCallback(\n    (nextValue: string) => {\n      if (!isControlled) {\n        setInternalValue(nextValue)\n      }\n      onValueChange?.(nextValue)\n    },\n    [isControlled, onValueChange],\n  )\n\n  useEffect(() => {\n    return () => {\n      if (debounceTimeoutRef.current !== null) {\n        window.clearTimeout(debounceTimeoutRef.current)\n      }\n    }\n  }, [])\n\n  const fetchSuggestions = useCallback(\n    async (input: string) => {\n      const trimmedInput = input.trim()\n      const requestId = requestIdRef.current + 1\n      requestIdRef.current = requestId\n\n      if (!trimmedInput || !isLoaded || !window.google?.maps) {\n        sessionTokenRef.current = null\n        setSuggestions([])\n        setOpen(false)\n        setActiveIndex(-1)\n        return\n      }\n\n      setLoadingSuggestions(true)\n\n      try {\n        const { AutocompleteSessionToken, AutocompleteSuggestion } =\n          await window.google.maps.importLibrary(\"places\")\n\n        if (!AutocompleteSuggestion || requestId !== requestIdRef.current) {\n          return\n        }\n\n        if (!sessionTokenRef.current) {\n          sessionTokenRef.current = new AutocompleteSessionToken()\n        }\n\n        const { suggestions: googleSuggestions } =\n          await AutocompleteSuggestion.fetchAutocompleteSuggestions({\n            input: trimmedInput,\n            includedRegionCodes: countryCode ? [countryCode] : [],\n            region: countryCode ?? \"\",\n            sessionToken: sessionTokenRef.current,\n          })\n\n        if (requestId !== requestIdRef.current) {\n          return\n        }\n\n        const nextSuggestions = googleSuggestions\n          .map((suggestion, index) => {\n            const prediction = suggestion.placePrediction\n            const label = prediction?.text?.text\n\n            if (!prediction || !label) {\n              return null\n            }\n\n            return {\n              id: `${prediction.placeId ?? label}-${index}`,\n              label,\n              prediction,\n            }\n          })\n          .filter((suggestion): suggestion is AddressSuggestion =>\n            Boolean(suggestion),\n          )\n\n        setSuggestions(nextSuggestions)\n        setOpen(nextSuggestions.length > 0)\n        setActiveIndex(nextSuggestions.length > 0 ? 0 : -1)\n      } catch {\n        setSuggestions([])\n        setOpen(false)\n        setActiveIndex(-1)\n      } finally {\n        if (requestId === requestIdRef.current) {\n          setLoadingSuggestions(false)\n        }\n      }\n    },\n    [countryCode, isLoaded],\n  )\n\n  const queueFetchSuggestions = useCallback(\n    (input: string) => {\n      if (debounceTimeoutRef.current !== null) {\n        window.clearTimeout(debounceTimeoutRef.current)\n      }\n\n      requestIdRef.current += 1\n      setLoadingSuggestions(false)\n\n      if (!input.trim()) {\n        sessionTokenRef.current = null\n        setSuggestions([])\n        setOpen(false)\n        setActiveIndex(-1)\n        return\n      }\n\n      debounceTimeoutRef.current = window.setTimeout(() => {\n        debounceTimeoutRef.current = null\n        void fetchSuggestions(input)\n      }, debounceMs)\n    },\n    [debounceMs, fetchSuggestions],\n  )\n\n  const handleSelectSuggestion = useCallback(\n    async (suggestion: AddressSuggestion) => {\n      const { prediction } = suggestion\n      setSelectingId(suggestion.id)\n      setOpen(false)\n      setSuggestions([])\n      setActiveIndex(-1)\n      setInputValue(suggestion.label)\n\n      try {\n        const place = prediction.toPlace?.()\n\n        if (!place) {\n          onPlaceSelect({\n            address: suggestion.label,\n            lat: null,\n            lng: null,\n            placeId: prediction.placeId ?? null,\n          })\n          return\n        }\n\n        await place.fetchFields({\n          fields: [\"formattedAddress\", \"location\"],\n        })\n\n        onPlaceSelect({\n          address: place.formattedAddress ?? suggestion.label,\n          lat: place.location?.lat() ?? null,\n          lng: place.location?.lng() ?? null,\n          placeId: prediction.placeId ?? null,\n        })\n      } catch {\n        onPlaceSelect({\n          address: suggestion.label,\n          lat: null,\n          lng: null,\n          placeId: prediction.placeId ?? null,\n        })\n      } finally {\n        sessionTokenRef.current = null\n        setSelectingId(null)\n      }\n    },\n    [onPlaceSelect, setInputValue],\n  )\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (!open || suggestions.length === 0) {\n      return\n    }\n\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault()\n      setActiveIndex((current) =>\n        current + 1 >= suggestions.length ? 0 : current + 1,\n      )\n      return\n    }\n\n    if (event.key === \"ArrowUp\") {\n      event.preventDefault()\n      setActiveIndex((current) =>\n        current - 1 < 0 ? suggestions.length - 1 : current - 1,\n      )\n      return\n    }\n\n    if (event.key === \"Enter\" && activeIndex >= 0) {\n      event.preventDefault()\n      const suggestion = suggestions[activeIndex]\n      if (suggestion) {\n        void handleSelectSuggestion(suggestion)\n      }\n      return\n    }\n\n    if (event.key === \"Escape\") {\n      event.preventDefault()\n      setOpen(false)\n      setActiveIndex(-1)\n    }\n  }\n\n  return (\n    <div\n      className={cn(\"relative w-full max-w-xl\", className)}\n    >\n      {GoogleMapsScript ? <GoogleMapsScript /> : null}\n\n      <div className=\"relative\">\n        <MapPin className=\"pointer-events-none absolute top-1/2 left-2.5 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground\" />\n        <Input\n          ref={inputRef}\n          type=\"text\"\n          role=\"combobox\"\n          aria-expanded={open}\n          aria-controls={listboxId}\n          aria-autocomplete=\"list\"\n          autoComplete=\"street-address\"\n          value={inputValue}\n          disabled={disabled || !hasApiKey}\n          placeholder={placeholder}\n          onChange={(event) => {\n            const nextValue = event.target.value\n            setInputValue(nextValue)\n            queueFetchSuggestions(nextValue)\n          }}\n          onFocus={() => {\n            if (suggestions.length > 0) {\n              setOpen(true)\n            }\n          }}\n          onBlur={() => {\n            window.setTimeout(() => setOpen(false), 120)\n          }}\n          onKeyDown={handleKeyDown}\n          className={cn(\"pl-9\", showPoweredByGoogle && \"pr-36\", inputClassName)}\n        />\n        {showPoweredByGoogle ? (\n          <span className=\"pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-[10px] font-medium whitespace-nowrap text-muted-foreground\">\n            Powered by Google\n          </span>\n        ) : null}\n      </div>\n\n      {mounted && open && dropdownRect\n        ? createPortal(\n            <div\n              id={listboxId}\n              role=\"listbox\"\n              style={{\n                position: \"fixed\",\n                top: dropdownRect.top,\n                left: dropdownRect.left,\n                width: dropdownRect.width,\n              }}\n              className=\"z-200 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-xl ring-1 ring-border/50\"\n            >\n              <div className=\"max-h-64 overflow-y-auto bg-popover p-1\">\n            {suggestions.map((suggestion, index) => {\n              const [primary, ...secondaryParts] = suggestion.label.split(\",\")\n              const secondary = secondaryParts.join(\",\").trim()\n              const isActive = index === activeIndex\n\n              return (\n                <button\n                  key={suggestion.id}\n                  type=\"button\"\n                  role=\"option\"\n                  aria-selected={isActive}\n                  onMouseDown={(event) => event.preventDefault()}\n                  onMouseEnter={() => setActiveIndex(index)}\n                  onClick={() => void handleSelectSuggestion(suggestion)}\n                  className={cn(\n                    \"flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left transition-colors\",\n                    isActive\n                      ? \"bg-accent text-accent-foreground\"\n                      : \"bg-popover hover:bg-accent\",\n                  )}\n                >\n                  <MapPin className=\"mt-0.5 h-4 w-4 shrink-0 text-primary\" />\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block truncate text-sm font-medium\">\n                      {primary}\n                    </span>\n                    {secondary ? (\n                      <span className=\"mt-0.5 block truncate text-xs text-muted-foreground\">\n                        {secondary}\n                      </span>\n                    ) : null}\n                  </span>\n                  {selectingId === suggestion.id ? (\n                    <span className=\"mt-0.5 text-xs text-muted-foreground\">\n                      Selecting\n                    </span>\n                  ) : null}\n                </button>\n              )\n            })}\n          </div>\n          {loadingSuggestions ? (\n                <div className=\"border-t border-border bg-popover px-3 py-2 text-xs text-muted-foreground\">\n                  Loading suggestions...\n                </div>\n              ) : null}\n            </div>,\n            document.body,\n          )\n        : null}\n\n      {!hasApiKey ? (\n        <p className=\"mt-1 text-xs text-muted-foreground\">\n          Set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to enable autocomplete.\n        </p>\n      ) : null}\n\n      {error ? (\n        <p className=\"mt-1 text-xs text-muted-foreground\">\n          {error}. You can still enter the address manually.\n        </p>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/new-york/places-autocomplete/hooks/use-google-places-script.tsx",
      "content": "\"use client\"\n\nimport Script from \"next/script\"\nimport { useCallback, useMemo, useState } from \"react\"\n\nexport type UseGooglePlacesScriptOptions = {\n  apiKey?: string\n  id?: string\n}\n\nexport function useGooglePlacesScript({\n  apiKey,\n  id = \"google-maps-places\",\n}: UseGooglePlacesScriptOptions = {}) {\n  const resolvedApiKey =\n    apiKey ?? process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? \"\"\n\n  const [isLoaded, setIsLoaded] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n\n  const src = useMemo(() => {\n    if (!resolvedApiKey) {\n      return null\n    }\n\n    return `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(\n      resolvedApiKey,\n    )}&libraries=places&loading=async`\n  }, [resolvedApiKey])\n\n  const handleReady = useCallback(() => {\n    setIsLoaded(true)\n    setError(null)\n  }, [])\n\n  const handleError = useCallback(() => {\n    setIsLoaded(false)\n    setError(\"Google Maps failed to load\")\n  }, [])\n\n  const GoogleMapsScript = useMemo(() => {\n    if (!src) {\n      return null\n    }\n\n    const scriptSrc = src\n\n    function GoogleMapsScriptComponent() {\n      return (\n        <Script\n          id={id}\n          src={scriptSrc}\n          strategy=\"afterInteractive\"\n          onLoad={handleReady}\n          onReady={handleReady}\n          onError={handleError}\n        />\n      )\n    }\n\n    return GoogleMapsScriptComponent\n  }, [handleError, handleReady, id, src])\n\n  return {\n    apiKey: resolvedApiKey,\n    isLoaded,\n    error,\n    hasApiKey: Boolean(resolvedApiKey),\n    GoogleMapsScript,\n  }\n}\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:component"
}