diff --git a/apps/ui/public/r/p-autocomplete-16.json b/apps/ui/public/r/p-autocomplete-16.json new file mode 100644 index 000000000..6f76617af --- /dev/null +++ b/apps/ui/public/r/p-autocomplete-16.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-autocomplete-16", + "description": "Address autocomplete with Google Maps Places API", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@coss/autocomplete", + "@coss/spinner" + ], + "files": [ + { + "path": "registry/default/particles/p-autocomplete-16.tsx", + "content": "\"use client\";\n\nimport { MapPinIcon } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n Autocomplete,\n AutocompleteInput,\n AutocompleteItem,\n AutocompleteList,\n AutocompletePopup,\n AutocompleteStatus,\n} from \"@/registry/default/ui/autocomplete\";\nimport { Spinner } from \"@/registry/default/ui/spinner\";\n\n// Set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY with the Places API (New) enabled to fetch\n// live suggestions. Without a key, the demo falls back to sample addresses.\nconst GOOGLE_MAPS_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? \"\";\n\ntype AddressSuggestion = {\n placeId: string;\n text: string;\n mainText: string;\n secondaryText: string;\n};\n\nconst sampleAddresses: AddressSuggestion[] = [\n {\n mainText: \"1600 Amphitheatre Parkway\",\n secondaryText: \"Mountain View, CA 94043, USA\",\n },\n {\n mainText: \"1600 Pennsylvania Avenue NW\",\n secondaryText: \"Washington, DC 20500, USA\",\n },\n { mainText: \"350 Fifth Avenue\", secondaryText: \"New York, NY 10118, USA\" },\n {\n mainText: \"221B Baker Street\",\n secondaryText: \"London NW1 6XE, United Kingdom\",\n },\n {\n mainText: \"Champ de Mars, 5 Avenue Anatole France\",\n secondaryText: \"75007 Paris, France\",\n },\n {\n mainText: \"Piazza del Colosseo, 1\",\n secondaryText: \"00184 Roma RM, Italy\",\n },\n { mainText: \"Platz der Republik 1\", secondaryText: \"11011 Berlin, Germany\" },\n {\n mainText: \"1 Macquarie Street\",\n secondaryText: \"Sydney NSW 2000, Australia\",\n },\n].map((address, index) => ({\n ...address,\n placeId: `sample-${index + 1}`,\n text: `${address.mainText}, ${address.secondaryText}`,\n}));\n\n// A short-lived session token groups Autocomplete + Place Details requests\n// for billing.\nfunction newSessionToken() {\n if (typeof crypto !== \"undefined\" && \"randomUUID\" in crypto) {\n return crypto.randomUUID();\n }\n return Math.random().toString(36).slice(2);\n}\n\ntype PlacesAutocompleteResponse = {\n suggestions?: {\n placePrediction?: {\n placeId?: string;\n text?: { text?: string };\n structuredFormat?: {\n mainText?: { text?: string };\n secondaryText?: { text?: string };\n };\n };\n }[];\n};\n\nasync function fetchAddressSuggestions(\n query: string,\n sessionToken: string,\n signal: AbortSignal,\n): Promise {\n const response = await fetch(\n \"https://places.googleapis.com/v1/places:autocomplete\",\n {\n body: JSON.stringify({ input: query, sessionToken }),\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-Goog-Api-Key\": GOOGLE_MAPS_API_KEY,\n },\n method: \"POST\",\n signal,\n },\n );\n if (!response.ok) {\n throw new Error(\"Places API request failed\");\n }\n const data = (await response.json()) as PlacesAutocompleteResponse;\n const suggestions: AddressSuggestion[] = [];\n for (const suggestion of data.suggestions ?? []) {\n const prediction = suggestion.placePrediction;\n if (!prediction?.placeId) continue;\n const text = prediction.text?.text ?? \"\";\n suggestions.push({\n mainText: prediction.structuredFormat?.mainText?.text ?? text,\n placeId: prediction.placeId,\n secondaryText: prediction.structuredFormat?.secondaryText?.text ?? \"\",\n text,\n });\n }\n return suggestions;\n}\n\nasync function searchSampleAddresses(\n query: string,\n): Promise {\n await new Promise((resolve) =>\n setTimeout(resolve, Math.random() * 500 + 100),\n );\n const lowerQuery = query.toLowerCase();\n return sampleAddresses.filter((address) =>\n address.text.toLowerCase().includes(lowerQuery),\n );\n}\n\nexport default function Particle() {\n const [searchValue, setSearchValue] = useState(\"\");\n const [isLoading, setIsLoading] = useState(false);\n const [suggestions, setSuggestions] = useState([]);\n const [error, setError] = useState(null);\n const sessionTokenRef = useRef(null);\n\n useEffect(() => {\n const query = searchValue.trim();\n if (!query) {\n setSuggestions([]);\n setIsLoading(false);\n setError(null);\n return;\n }\n\n setIsLoading(true);\n setError(null);\n let ignore = false;\n const controller = new AbortController();\n\n const timeoutId = setTimeout(async () => {\n try {\n sessionTokenRef.current ??= newSessionToken();\n const results = GOOGLE_MAPS_API_KEY\n ? await fetchAddressSuggestions(\n query,\n sessionTokenRef.current,\n controller.signal,\n )\n : await searchSampleAddresses(query);\n if (!ignore) setSuggestions(results);\n } catch {\n if (!ignore && !controller.signal.aborted) {\n setError(\"Could not load address suggestions. Please try again.\");\n setSuggestions([]);\n }\n } finally {\n if (!ignore) setIsLoading(false);\n }\n }, 300);\n\n return () => {\n ignore = true;\n controller.abort();\n clearTimeout(timeoutId);\n };\n }, [searchValue]);\n\n let status: ReactNode = `${suggestions.length} suggestion${suggestions.length === 1 ? \"\" : \"s\"} found`;\n if (isLoading) {\n status = (\n \n Searching addresses...\n \n \n );\n } else if (error) {\n status = (\n {error}\n );\n } else if (suggestions.length === 0 && searchValue) {\n status = (\n \n No addresses found for \"{searchValue}\"\n \n );\n }\n\n const shouldRenderPopup = searchValue.trim() !== \"\";\n\n return (\n (item as AddressSuggestion).text}\n onValueChange={(value, eventDetails) => {\n setSearchValue(value);\n if (eventDetails.reason === \"item-press\") {\n // Selecting a suggestion ends the billing session. In a real app,\n // fetch the place details with the same token before resetting it.\n sessionTokenRef.current = null;\n }\n }}\n value={searchValue}\n >\n }\n />\n {shouldRenderPopup && (\n \n \n {status}\n \n \n {(suggestion: AddressSuggestion) => (\n \n \n \n {suggestion.mainText}\n \n \n {suggestion.secondaryText}\n \n \n \n )}\n \n \n )}\n \n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-64" + }, + "categories": [ + "autocomplete", + "input", + "async", + "search" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index 77b71b589..4fed69295 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -1571,6 +1571,33 @@ ], "type": "registry:block" }, + { + "categories": [ + "autocomplete", + "input", + "async", + "search" + ], + "dependencies": [ + "lucide-react" + ], + "description": "Address autocomplete with Google Maps Places API", + "files": [ + { + "path": "registry/default/particles/p-autocomplete-16.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-64" + }, + "name": "p-autocomplete-16", + "registryDependencies": [ + "@coss/autocomplete", + "@coss/spinner" + ], + "type": "registry:block" + }, { "categories": [ "avatar" diff --git a/apps/ui/registry.json b/apps/ui/registry.json index 77b71b589..4fed69295 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -1571,6 +1571,33 @@ ], "type": "registry:block" }, + { + "categories": [ + "autocomplete", + "input", + "async", + "search" + ], + "dependencies": [ + "lucide-react" + ], + "description": "Address autocomplete with Google Maps Places API", + "files": [ + { + "path": "registry/default/particles/p-autocomplete-16.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-64" + }, + "name": "p-autocomplete-16", + "registryDependencies": [ + "@coss/autocomplete", + "@coss/spinner" + ], + "type": "registry:block" + }, { "categories": [ "avatar" diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index da6834fcf..aa3aeda9a 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -1483,6 +1483,24 @@ export const Index: Record = { categories: ["autocomplete","input"], meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-64"}, }, + "p-autocomplete-16": { + name: "p-autocomplete-16", + description: "Address autocomplete with Google Maps Places API", + type: "registry:block", + registryDependencies: ["@coss/autocomplete","@coss/spinner"], + files: [{ + path: "registry/default/particles/p-autocomplete-16.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-autocomplete-16.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["autocomplete","input","async","search"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-64"}, + }, "p-avatar-1": { name: "p-avatar-1", description: "Avatar with image and fallback", diff --git a/apps/ui/registry/default/particles/p-autocomplete-16.tsx b/apps/ui/registry/default/particles/p-autocomplete-16.tsx new file mode 100644 index 000000000..479632ec4 --- /dev/null +++ b/apps/ui/registry/default/particles/p-autocomplete-16.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { MapPinIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; +import { + Autocomplete, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompletePopup, + AutocompleteStatus, +} from "@/registry/default/ui/autocomplete"; +import { Spinner } from "@/registry/default/ui/spinner"; + +// Set NEXT_PUBLIC_GOOGLE_MAPS_API_KEY with the Places API (New) enabled to fetch +// live suggestions. Without a key, the demo falls back to sample addresses. +const GOOGLE_MAPS_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? ""; + +type AddressSuggestion = { + placeId: string; + text: string; + mainText: string; + secondaryText: string; +}; + +const sampleAddresses: AddressSuggestion[] = [ + { + mainText: "1600 Amphitheatre Parkway", + secondaryText: "Mountain View, CA 94043, USA", + }, + { + mainText: "1600 Pennsylvania Avenue NW", + secondaryText: "Washington, DC 20500, USA", + }, + { mainText: "350 Fifth Avenue", secondaryText: "New York, NY 10118, USA" }, + { + mainText: "221B Baker Street", + secondaryText: "London NW1 6XE, United Kingdom", + }, + { + mainText: "Champ de Mars, 5 Avenue Anatole France", + secondaryText: "75007 Paris, France", + }, + { + mainText: "Piazza del Colosseo, 1", + secondaryText: "00184 Roma RM, Italy", + }, + { mainText: "Platz der Republik 1", secondaryText: "11011 Berlin, Germany" }, + { + mainText: "1 Macquarie Street", + secondaryText: "Sydney NSW 2000, Australia", + }, +].map((address, index) => ({ + ...address, + placeId: `sample-${index + 1}`, + text: `${address.mainText}, ${address.secondaryText}`, +})); + +// A short-lived session token groups Autocomplete + Place Details requests +// for billing. +function newSessionToken() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return Math.random().toString(36).slice(2); +} + +type PlacesAutocompleteResponse = { + suggestions?: { + placePrediction?: { + placeId?: string; + text?: { text?: string }; + structuredFormat?: { + mainText?: { text?: string }; + secondaryText?: { text?: string }; + }; + }; + }[]; +}; + +async function fetchAddressSuggestions( + query: string, + sessionToken: string, + signal: AbortSignal, +): Promise { + const response = await fetch( + "https://places.googleapis.com/v1/places:autocomplete", + { + body: JSON.stringify({ input: query, sessionToken }), + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": GOOGLE_MAPS_API_KEY, + }, + method: "POST", + signal, + }, + ); + if (!response.ok) { + throw new Error("Places API request failed"); + } + const data = (await response.json()) as PlacesAutocompleteResponse; + const suggestions: AddressSuggestion[] = []; + for (const suggestion of data.suggestions ?? []) { + const prediction = suggestion.placePrediction; + if (!prediction?.placeId) continue; + const text = prediction.text?.text ?? ""; + suggestions.push({ + mainText: prediction.structuredFormat?.mainText?.text ?? text, + placeId: prediction.placeId, + secondaryText: prediction.structuredFormat?.secondaryText?.text ?? "", + text, + }); + } + return suggestions; +} + +async function searchSampleAddresses( + query: string, +): Promise { + await new Promise((resolve) => + setTimeout(resolve, Math.random() * 500 + 100), + ); + const lowerQuery = query.toLowerCase(); + return sampleAddresses.filter((address) => + address.text.toLowerCase().includes(lowerQuery), + ); +} + +export default function Particle() { + const [searchValue, setSearchValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [error, setError] = useState(null); + const sessionTokenRef = useRef(null); + + useEffect(() => { + const query = searchValue.trim(); + if (!query) { + setSuggestions([]); + setIsLoading(false); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + let ignore = false; + const controller = new AbortController(); + + const timeoutId = setTimeout(async () => { + try { + sessionTokenRef.current ??= newSessionToken(); + const results = GOOGLE_MAPS_API_KEY + ? await fetchAddressSuggestions( + query, + sessionTokenRef.current, + controller.signal, + ) + : await searchSampleAddresses(query); + if (!ignore) setSuggestions(results); + } catch { + if (!ignore && !controller.signal.aborted) { + setError("Could not load address suggestions. Please try again."); + setSuggestions([]); + } + } finally { + if (!ignore) setIsLoading(false); + } + }, 300); + + return () => { + ignore = true; + controller.abort(); + clearTimeout(timeoutId); + }; + }, [searchValue]); + + let status: ReactNode = `${suggestions.length} suggestion${suggestions.length === 1 ? "" : "s"} found`; + if (isLoading) { + status = ( + + Searching addresses... + + + ); + } else if (error) { + status = ( + {error} + ); + } else if (suggestions.length === 0 && searchValue) { + status = ( + + No addresses found for "{searchValue}" + + ); + } + + const shouldRenderPopup = searchValue.trim() !== ""; + + return ( + (item as AddressSuggestion).text} + onValueChange={(value, eventDetails) => { + setSearchValue(value); + if (eventDetails.reason === "item-press") { + // Selecting a suggestion ends the billing session. In a real app, + // fetch the place details with the same token before resetting it. + sessionTokenRef.current = null; + } + }} + value={searchValue} + > + } + /> + {shouldRenderPopup && ( + + + {status} + + + {(suggestion: AddressSuggestion) => ( + + + + {suggestion.mainText} + + + {suggestion.secondaryText} + + + + )} + + + )} + + ); +} diff --git a/apps/ui/registry/registry-particles.ts b/apps/ui/registry/registry-particles.ts index ae3a44deb..48a6c8db3 100644 --- a/apps/ui/registry/registry-particles.ts +++ b/apps/ui/registry/registry-particles.ts @@ -356,6 +356,21 @@ export const particles: ParticleItem[] = [ registryDependencies: ["@coss/autocomplete"], type: "registry:block", }, + { + categories: categories("autocomplete", "input", "async", "search"), + dependencies: ["lucide-react"], + description: "Address autocomplete with Google Maps Places API", + files: [ + { path: "particles/p-autocomplete-16.tsx", type: "registry:block" }, + ], + meta: { + className: + "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-64", + }, + name: "p-autocomplete-16", + registryDependencies: ["@coss/autocomplete", "@coss/spinner"], + type: "registry:block", + }, { categories: categories("avatar"), description: "Avatar with image and fallback",