Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/ui/public/r/p-autocomplete-16.json
Original file line number Diff line number Diff line change
@@ -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<AddressSuggestion[]> {\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<AddressSuggestion[]> {\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<AddressSuggestion[]>([]);\n const [error, setError] = useState<string | null>(null);\n const sessionTokenRef = useRef<string | null>(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 <span className=\"flex items-center justify-between gap-2 text-muted-foreground\">\n Searching addresses...\n <Spinner className=\"size-4.5 sm:size-4\" />\n </span>\n );\n } else if (error) {\n status = (\n <span className=\"font-normal text-destructive text-sm\">{error}</span>\n );\n } else if (suggestions.length === 0 && searchValue) {\n status = (\n <span className=\"font-normal text-muted-foreground text-sm\">\n No addresses found for \"{searchValue}\"\n </span>\n );\n }\n\n const shouldRenderPopup = searchValue.trim() !== \"\";\n\n return (\n <Autocomplete\n autoHighlight\n filter={null}\n items={suggestions}\n itemToStringValue={(item: unknown) => (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 <AutocompleteInput\n aria-label=\"Address\"\n autoComplete=\"off\"\n className=\"min-w-0 *:[input]:truncate\"\n placeholder=\"Enter an address\"\n startAddon={<MapPinIcon />}\n />\n {shouldRenderPopup && (\n <AutocompletePopup\n aria-busy={isLoading || undefined}\n className=\"max-w-(--anchor-width) *:min-w-0\"\n >\n <AutocompleteStatus className=\"text-muted-foreground\">\n {status}\n </AutocompleteStatus>\n <AutocompleteList>\n {(suggestion: AddressSuggestion) => (\n <AutocompleteItem key={suggestion.placeId} value={suggestion}>\n <span className=\"flex w-full min-w-0 flex-col\">\n <span className=\"truncate font-medium\">\n {suggestion.mainText}\n </span>\n <span className=\"truncate text-muted-foreground text-xs\">\n {suggestion.secondaryText}\n </span>\n </span>\n </AutocompleteItem>\n )}\n </AutocompleteList>\n </AutocompletePopup>\n )}\n </Autocomplete>\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"
}
27 changes: 27 additions & 0 deletions apps/ui/public/r/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions apps/ui/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions apps/ui/registry/__index__.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,24 @@ export const Index: Record<string, any> = {
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",
Expand Down
250 changes: 250 additions & 0 deletions apps/ui/registry/default/particles/p-autocomplete-16.tsx
Original file line number Diff line number Diff line change
@@ -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<AddressSuggestion[]> {
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<AddressSuggestion[]> {
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<AddressSuggestion[]>([]);
const [error, setError] = useState<string | null>(null);
const sessionTokenRef = useRef<string | null>(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 = (
<span className="flex items-center justify-between gap-2 text-muted-foreground">
Searching addresses...
<Spinner className="size-4.5 sm:size-4" />
</span>
);
} else if (error) {
status = (
<span className="font-normal text-destructive text-sm">{error}</span>
);
} else if (suggestions.length === 0 && searchValue) {
status = (
<span className="font-normal text-muted-foreground text-sm">
No addresses found for "{searchValue}"
</span>
);
}

const shouldRenderPopup = searchValue.trim() !== "";

return (
<Autocomplete
autoHighlight
filter={null}
items={suggestions}
itemToStringValue={(item: unknown) => (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}
>
<AutocompleteInput
aria-label="Address"
autoComplete="off"
className="min-w-0 *:[input]:truncate"
placeholder="Enter an address"
startAddon={<MapPinIcon />}
/>
{shouldRenderPopup && (
<AutocompletePopup
aria-busy={isLoading || undefined}
className="max-w-(--anchor-width) *:min-w-0"
>
<AutocompleteStatus className="text-muted-foreground">
{status}
</AutocompleteStatus>
<AutocompleteList>
{(suggestion: AddressSuggestion) => (
<AutocompleteItem key={suggestion.placeId} value={suggestion}>
<span className="flex w-full min-w-0 flex-col">
<span className="truncate font-medium">
{suggestion.mainText}
</span>
<span className="truncate text-muted-foreground text-xs">
{suggestion.secondaryText}
</span>
</span>
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompletePopup>
)}
</Autocomplete>
);
}
15 changes: 15 additions & 0 deletions apps/ui/registry/registry-particles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading