From 797fe1c9057c5074d8abdd4b2619582b2ac2dbfb Mon Sep 17 00:00:00 2001 From: Florian Dreyer Date: Wed, 10 Dec 2025 15:00:45 +0100 Subject: [PATCH 1/5] chore(docs): refactor of places-ui-kit example into dedicated components for UI Kit elements --- examples/places-ui-kit/README.md | 5 +- examples/places-ui-kit/src/app.tsx | 203 ++++++++++++++---- .../components/autocomplete-webcomponent.tsx | 71 ------ .../components/basic-place-autocomplete.tsx | 145 +++++++++++++ .../src/components/place-content-config.tsx | 139 ++++++++++++ .../src/components/place-details-marker.tsx | 83 ------- .../src/components/place-details.tsx | 140 ++++++++++++ .../components/place-search-webcomponent.tsx | 110 ---------- .../src/components/place-search.tsx | 162 ++++++++++++++ .../src/components/search-bar.tsx | 62 ------ .../src/place-details-marker.tsx | 59 +++++ 11 files changed, 808 insertions(+), 371 deletions(-) delete mode 100644 examples/places-ui-kit/src/components/autocomplete-webcomponent.tsx create mode 100644 examples/places-ui-kit/src/components/basic-place-autocomplete.tsx create mode 100644 examples/places-ui-kit/src/components/place-content-config.tsx delete mode 100644 examples/places-ui-kit/src/components/place-details-marker.tsx create mode 100644 examples/places-ui-kit/src/components/place-details.tsx delete mode 100644 examples/places-ui-kit/src/components/place-search-webcomponent.tsx create mode 100644 examples/places-ui-kit/src/components/place-search.tsx delete mode 100644 examples/places-ui-kit/src/components/search-bar.tsx create mode 100644 examples/places-ui-kit/src/place-details-marker.tsx diff --git a/examples/places-ui-kit/README.md b/examples/places-ui-kit/README.md index d860f5e2..7c381cdd 100644 --- a/examples/places-ui-kit/README.md +++ b/examples/places-ui-kit/README.md @@ -2,12 +2,11 @@ ![image](https://user-images.githubusercontent.com/39244966/208682692-d5b23518-9e51-4a87-8121-29f71e41c777.png) -This is an example to show how to setup the Google Places UI Kit (Preview) webcomponents +This is an example to show how to setup the Google Places UI Kit webcomponents -## Enable the APIs +## Enable the API To use the Places UI Kit you need to enable the [API][places-ui-kit] in the Cloud Console. -Additionally for the elevation component you need the [Elevation API][elevation-api] ## Google Maps Platform API Key diff --git a/examples/places-ui-kit/src/app.tsx b/examples/places-ui-kit/src/app.tsx index 6f1810b1..d55c46df 100644 --- a/examples/places-ui-kit/src/app.tsx +++ b/examples/places-ui-kit/src/app.tsx @@ -1,12 +1,17 @@ -import React, {useState, useMemo} from 'react'; +import { + APIProvider, + Map, + useMap, + useMapsLibrary +} from '@vis.gl/react-google-maps'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {createRoot} from 'react-dom/client'; -import {APIProvider, Map} from '@vis.gl/react-google-maps'; -import {PlaceDetailsMarker} from './components/place-details-marker'; -import {PlaceSearchWebComponent} from './components/place-search-webcomponent'; -import {SearchBar} from './components/search-bar'; +import {BasicPlaceAutocomplete} from './components/basic-place-autocomplete'; +import {NearbySearchOptions, PlaceSearch} from './components/place-search'; import ControlPanel from './control-panel'; +import PlaceDetailsMarker from './place-details-marker'; import './styles.css'; @@ -17,11 +22,12 @@ if (!API_KEY) { console.error('Missing Google Maps API key'); } -export type PlaceType = - | 'restaurant' - | 'cafe' - | 'electric_vehicle_charging_station' - | null; +type PlaceType = 'restaurant' | 'cafe' | 'electric_vehicle_charging_station'; + +type PlaceTypeOption = { + value: PlaceType; + label: string; +}; export type DetailsSize = 'FULL' | 'COMPACT'; export type ColorScheme = 'light' | 'dark'; @@ -36,6 +42,15 @@ const MAP_CONFIG = { }; const App = () => { + // APIProvider sets up the Google Maps JavaScript API with the specified key + return ( + + + + ); +}; + +const Layout = () => { const [places, setPlaces] = useState([]); const [selectedPlaceId, setSelectedPlaceId] = useState(null); const [locationId, setLocationId] = useState(null); @@ -43,6 +58,82 @@ const App = () => { const [detailsSize, setDetailsSize] = useState('FULL'); const [colorScheme, setColorScheme] = useState('light'); + const geoLib = useMapsLibrary('geometry'); + + const map = useMap(); + + const [nearbySearch, setNearbySearch] = useState< + NearbySearchOptions | undefined + >(); + + const placeTypeOptions: PlaceTypeOption[] = useMemo( + () => [ + {value: 'restaurant', label: 'Restaurants'}, + {value: 'cafe', label: 'Cafes'}, + {value: 'electric_vehicle_charging_station', label: 'EV Charging'} + ], + [] + ); + + // Calculate a circular region based on the map's current bounds + // This is used to restrict the places search to the visible map area + const getContainingCircle = useCallback( + (bounds?: google.maps.LatLngBounds): google.maps.CircleLiteral => { + if (!bounds || !geoLib) + return {center: MAP_CONFIG.defaultCenter, radius: 2800}; + + // Calculate diameter between the northeast and southwest corners of the bounds + const diameter = geoLib.spherical.computeDistanceBetween( + bounds.getNorthEast(), + bounds.getSouthWest() + ); + const calculatedRadius = diameter / 2; + + // Cap the radius at 50km to avoid exceeding Google Maps API limits + const cappedRadius = Math.min(calculatedRadius, 50000); + return {center: bounds.getCenter(), radius: cappedRadius}; + }, + [geoLib] + ); + + const handlePlaceSelect = useCallback( + async (place: google.maps.places.Place) => { + try { + // Fetch viewport data for the selected place + await place.fetchFields({ + fields: ['viewport'] + }); + + // If the place has a viewport (area boundaries), adjust the map to show it + if (place?.viewport) { + map?.fitBounds(place.viewport); + } + + setLocationId(place.id); + } catch (error) { + console.error('Error fetching place fields:', error); + setLocationId(null); + } + }, + [map, setLocationId] + ); + + // On location change via autocomplete update nearbysearch options for the place search component + useEffect(() => { + if (!geoLib || !map) { + return; + } + const bounds = map.getBounds(); + const circle = getContainingCircle(bounds); + + if (!circle) return; + + setNearbySearch({ + locationRestriction: circle, + includedPrimaryTypes: placeType ? [placeType] : undefined + }); + }, [geoLib, map, placeType, getContainingCircle, locationId]); + // Memoize the place markers to prevent unnecessary re-renders // Only recreate when places, selection, or details size changes const placeMarkers = useMemo(() => { @@ -58,57 +149,85 @@ const App = () => { }, [places, selectedPlaceId, detailsSize]); return ( - // APIProvider sets up the Google Maps JavaScript API with the specified key - // Using 'alpha' version to access the latest features including UI Kit components - -
-
+
+
+
{/* - PlaceSearchtWebComponent displays a list of places based on: + The PlaceSearch component displays a list of places based on: - The selected place type (restaurant, cafe, etc.) - The current map location and bounds */} - setSelectedPlaceId(place?.id ?? null)} + setPlaces(e.target.places)} + onSelect={e => setSelectedPlaceId(e.place?.id ?? null)} />
- -
- {/* +
+
+ {/* The Map component renders the Google Map Clicking on the map background will deselect any selected place */} - setSelectedPlaceId(null)}> - {placeMarkers} - + setSelectedPlaceId(null)}> + {placeMarkers} + - {/* + {/* SearchBar allows users to: - Select the type of place they want to find - Search for a specific location to center the map on */} - +
+ + + near +
+ {/* + The BasicAutocomplete component allows users to search for a place and returns its place id + */} + handlePlaceSelect(e.place)} + style={{borderRadius: '1rem'}}> + + + + +
+
- {/* + {/* ControlPanel provides UI controls for adjusting the size of place details displayed in the InfoWindow */} - -
+
- +
); }; export default App; diff --git a/examples/places-ui-kit/src/components/autocomplete-webcomponent.tsx b/examples/places-ui-kit/src/components/autocomplete-webcomponent.tsx deleted file mode 100644 index 60a8db5a..00000000 --- a/examples/places-ui-kit/src/components/autocomplete-webcomponent.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, {useCallback} from 'react'; -import {useMap, useMapsLibrary} from '@vis.gl/react-google-maps'; - -interface Props { - onPlaceSelect: (place: google.maps.places.Place | null) => void; -} - -interface GmpSelectEvent { - place: google.maps.places.Place; -} - -export const AutocompleteWebComponent = ({onPlaceSelect}: Props) => { - // Load the places library to ensure the web component is available - useMapsLibrary('places'); - - const map = useMap(); - - // Handle the selection of a place from the autocomplete component - // This fetches additional place details and adjusts the map view - const handlePlaceSelect = useCallback( - async (place: google.maps.places.Place) => { - try { - // Fetch location and viewport data for the selected place - await place.fetchFields({ - fields: ['location', 'viewport'] - }); - - // If the place has a viewport (area boundaries), adjust the map to show it - if (place?.viewport) { - map?.fitBounds(place.viewport); - } - - onPlaceSelect(place); - } catch (error) { - console.error('Error fetching place fields:', error); - onPlaceSelect(null); - } - }, - [map, onPlaceSelect] - ); - - // Handle the gmp-select event, which returns a Place object, that contains only a place ID - const handleGmpSelect = useCallback( - (event: GmpSelectEvent) => { - void handlePlaceSelect(event.place); - }, - [handlePlaceSelect] - ); - - // Note: This is a React 19 thing to be able to treat custom elements this way. - // In React before v19, you'd have to use a ref, or use the BasicPlaceAutocompleteElement - // constructor instead. - return ( -
- {/* - gmp-place-autocomplete is a Google Maps Web Component that provides a search box - with automatic place suggestions as the user types. - - It supports two event types for backward compatibility: - - ongmp-select: Used in alpha and future stable versions - - ongmp-placeselect: Deprecated but still used in beta channel - */} - -
- ); -}; - -AutocompleteWebComponent.displayName = 'AutocompleteWebComponent'; diff --git a/examples/places-ui-kit/src/components/basic-place-autocomplete.tsx b/examples/places-ui-kit/src/components/basic-place-autocomplete.tsx new file mode 100644 index 00000000..ecb17400 --- /dev/null +++ b/examples/places-ui-kit/src/components/basic-place-autocomplete.tsx @@ -0,0 +1,145 @@ +import {useMapsLibrary} from '@vis.gl/react-google-maps'; +import React, { + FunctionComponent, + PropsWithChildren, + useEffect, + useRef, + useState +} from 'react'; +import {createPortal} from 'react-dom'; + +export type BasicPlaceAutocompleteElementProps = PropsWithChildren<{ + /** + * Classname to pass on to gmp-basic-place-autocomplete + */ + className?: string; + /** + * CSS properties to pass on to gmp-basic-place-autocomplete + */ + style?: React.CSSProperties; + /** + * Restricts predictions to a set of pre-defined primary types. + */ + includedPrimaryTypes?: Array | null; + /** + * Restricts predictions to a set of pre-defined region codes. + */ + includedRegionCodes?: Array | null; + /** + * Bias the results towards a particular area. + */ + locationBias?: google.maps.places.LocationBias | null; + /** + * Restrict the results to a particular area. + */ + locationRestriction?: google.maps.places.LocationRestriction | null; + /** + * The name of the autocomplete element. + */ + name?: string | null; + /** + * The origin point from which to calculate distances. + */ + origin?: + | google.maps.LatLng + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | null; + /** + * The language to use for the autocomplete predictions. + */ + requestedLanguage?: string | null; + /** + * The region code to use for the autocomplete predictions. + */ + requestedRegion?: string | null; + /** + * The unit system to use for the autocomplete predictions. + */ + unitSystem?: google.maps.UnitSystem; + /** + * Fired when a place is selected from the autocomplete predictions. + */ + onSelect?: ({place}: {place: google.maps.places.Place}) => void; + /** + * Fired when an error occurs. + */ + onError?: (e: google.maps.places.PlaceAutocompleteRequestErrorEvent) => void; +}>; + +/** + * Wrapper component for gmp-basic-place-autocomplete + * Properties and styling options according to https://developers.google.com/maps/documentation/javascript/reference/places-widget#BasicPlaceAutocompleteElement + */ +export const BasicPlaceAutocomplete: FunctionComponent< + BasicPlaceAutocompleteElementProps +> = props => { + const { + children, + className, + style, + includedPrimaryTypes, + includedRegionCodes, + locationBias, + locationRestriction, + name, + origin, + requestedLanguage, + requestedRegion, + unitSystem, + onSelect, + onError + } = props; + + const placesLibrary = useMapsLibrary('places'); + + const ref = useRef(null); + + const [templateElement, setTemplateElement] = + useState(null); + + useEffect(() => { + const autocomplete = ref.current; + if (!placesLibrary || !autocomplete || !children) return; + + const template = document.createElement('template'); + template.setAttribute('slot', 'prediction-item-icon'); + + autocomplete.appendChild(template); + setTemplateElement(template); + + return () => { + setTemplateElement(null); + if (autocomplete.contains(template)) { + autocomplete.removeChild(template); + } + }; + }, [placesLibrary, children]); + + if (!placesLibrary) return null; + + return ( + + {templateElement && + children && + createPortal(children, templateElement.content)} + + ); +}; + +BasicPlaceAutocomplete.displayName = 'BasicPlaceAutocomplete'; diff --git a/examples/places-ui-kit/src/components/place-content-config.tsx b/examples/places-ui-kit/src/components/place-content-config.tsx new file mode 100644 index 00000000..38f4a26a --- /dev/null +++ b/examples/places-ui-kit/src/components/place-content-config.tsx @@ -0,0 +1,139 @@ +import React, {FunctionComponent} from 'react'; + +export const ContentConfig = { + STANDARD: 'standard', + ALL: 'all', + CUSTOM: 'custom' +} as const; + +export type ContentConfig = (typeof ContentConfig)[keyof typeof ContentConfig]; + +// --- 2. Custom Element Options --- +// These are the possible children for gmp-place-content-config when type is 'custom'. +// Note: We've made these specific to PlaceDetailsElement as per the prompt. +export type PlaceDetailsContentItem = + | 'address' + | 'accessible-entrance-icon' + | 'attribution' + | 'media' + | 'open-now-status' + | 'price' + | 'rating' + | 'type' + // noncompact only + | 'feature-list' + | 'opening-hours' + | 'phone-number' + | 'plus-code' + | 'reviews' + | 'summary' + | 'type-specific-highlights' + | 'website'; + +type AttributionContentItem = { + attribute: 'attribution'; + options?: { + lightSchemeColor?: google.maps.places.AttributionColor | null; + darkSchemeColor?: google.maps.places.AttributionColor | null; + }; +}; + +type MediaContentItem = { + attribute: 'media'; + options?: { + lightboxPreferred?: boolean; + // FIXME: update to google.maps.places.MediaSize + preferredSize?: 'SMALL' | 'MEDIUM' | 'LARGE'; + }; +}; + +type DefaultContentItem = { + attribute: Omit; + options?: never; +}; + +export type ContentItem = + | AttributionContentItem + | MediaContentItem + | DefaultContentItem; + +export type PlaceContentConfigProps = { + contentConfig: ContentConfig; + /** + * Required only if type is 'custom'. + * The array lists the content elements to display. + */ + customContent?: Array; +}; + +/** + * Maps a content item string to its corresponding Google Maps Web Component JSX. + */ +const renderContentItem = (itemConfig: ContentItem, key: number) => { + switch (itemConfig.attribute) { + case 'address': + return ; + case 'accessible-entrance-icon': + return ; + case 'attribution': + return ; + case 'media': + return ; + case 'open-now-status': + return ; + case 'price': + return ; + case 'rating': + return ; + case 'type': + return ; + case 'feature-list': + return ; + case 'opening-hours': + return ; + case 'phone-number': + return ; + case 'plus-code': + return ; + case 'reviews': + return ; + case 'summary': + return ; + case 'type-specific-highlights': + return ; + case 'website': + return ; + default: + return null; + } +}; + +export const PlaceContentConfig: FunctionComponent< + PlaceContentConfigProps +> = props => { + const {contentConfig, customContent} = props; + + // Determine the content element to render based on the config type + switch (contentConfig) { + case ContentConfig.STANDARD: + return ; + case ContentConfig.ALL: + return ; + case ContentConfig.CUSTOM: + if (!customContent || customContent.length === 0) { + console.error( + 'PlaceContentConfig: customContent must be provided for type "custom".' + ); + return null; + } + + // Render the custom config element with its children + return ( + + {customContent.map(renderContentItem)} + + ); + default: + return null; + } +}; diff --git a/examples/places-ui-kit/src/components/place-details-marker.tsx b/examples/places-ui-kit/src/components/place-details-marker.tsx deleted file mode 100644 index 31c010ca..00000000 --- a/examples/places-ui-kit/src/components/place-details-marker.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, {memo, useCallback} from 'react'; -import { - AdvancedMarker, - InfoWindow, - useAdvancedMarkerRef, - useMapsLibrary -} from '@vis.gl/react-google-maps'; - -import {DetailsSize} from '../app'; - -interface PlaceDetailsMarkerProps { - place: google.maps.places.Place; - selected: boolean; - onClick: (placeId: string | null) => void; - detailsSize: DetailsSize; -} - -const PlaceDetailsMarkerComponent = ({ - place, - selected, - onClick, - detailsSize -}: PlaceDetailsMarkerProps) => { - const [markerRef, marker] = useAdvancedMarkerRef(); - - // Load required Google Maps library for places - useMapsLibrary('places'); - - // Handle marker click to select this place - const handleMarkerClick = useCallback(() => { - onClick(place.id); - }, [onClick, place.id]); - - // Handle info window close by deselecting this place - const handleCloseClick = useCallback(() => { - onClick(null); - }, [onClick]); - - return ( - <> - - {selected && ( - - {/* - gmp-place-details is a Google Maps Web Component that displays detailed information - about a place, including photos, reviews, open hours, etc. - The size parameter controls how much information is displayed. - - - gmp-place-details-compact is a Google Maps Web Component that displays a lot of the same - information as the full version but in a more compact format. - */} - {detailsSize === 'FULL' ? ( - - - - - ) : ( - - - - - )} - - )} - - ); -}; - -PlaceDetailsMarkerComponent.displayName = 'PlaceDetailsMarker'; - -export const PlaceDetailsMarker = memo(PlaceDetailsMarkerComponent); diff --git a/examples/places-ui-kit/src/components/place-details.tsx b/examples/places-ui-kit/src/components/place-details.tsx new file mode 100644 index 00000000..68161e5b --- /dev/null +++ b/examples/places-ui-kit/src/components/place-details.tsx @@ -0,0 +1,140 @@ +import {useMapsLibrary} from '@vis.gl/react-google-maps'; +import React, {FunctionComponent} from 'react'; +import { + ContentConfig, + ContentItem, + PlaceContentConfig +} from './place-content-config'; + +export const Orientation = { + HORIZONTAL: 'HORIZONTAL', + VERTICAL: 'VERTICAL' +} as const; + +export type Orientation = (typeof Orientation)[keyof typeof Orientation]; + +export type PlaceDetailsProps = { + /** + * Classname to pass on to gmp-place-details (compact and non-compact) + */ + className?: string; + /** + * CSS properties to pass on to gmp-place-details (compact and non-compact) + */ + style?: React.CSSProperties; + + /** + * If true, shows the compact version of place-details + */ + compact?: boolean; + + /** + * Show details for a place via its place id or via its location. + * the place id takes priority if both are provided + */ + placeId?: string; + /** + * Location of the place. + */ + location?: + | google.maps.LatLng + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | null; + + /** + * Content config. Defaults to 'standard' + */ + contentConfig: ContentConfig; + /** + * Required only if type is 'custom'. + * The array lists the content elements to display. + */ + customContent?: Array; + + // compact only + /** + * Orientation of the displayed information. Defaults to vertical. + */ + orientation?: Orientation; + /** + * Whether or not text should be truncated. + */ + truncationPreferred?: boolean; + + /** + * Fired when the place details are loaded. + */ + onLoad?: ({ + target: {place} + }: { + target: {place: google.maps.places.Place}; + }) => void; + /** + * Fired on error. + */ + onError?: (e: Event) => void; +}; + +/** + * Wrapper component for gmp-place-details / gmp-place-details-compact + * Properties and styling options according to https://developers.google.com/maps/documentation/javascript/reference/places-widget#PlaceDetailsElement + * and https://developers.google.com/maps/documentation/javascript/reference/places-widget#PlaceDetailsCompactElement + */ +export const PlaceDetails: FunctionComponent = props => { + const { + className, + style, + compact = false, + placeId, + location, + contentConfig = ContentConfig.STANDARD, + customContent, + orientation = Orientation.VERTICAL, + truncationPreferred, + onLoad, + onError + } = props; + + // Load required Google Maps library for places + const placesLibrary = useMapsLibrary('places'); + + if (!placesLibrary || !(placeId || location)) return null; + + return compact ? ( + + + + + + ) : ( + + + + + + ); +}; + +PlaceDetails.displayName = 'PlaceDetails'; diff --git a/examples/places-ui-kit/src/components/place-search-webcomponent.tsx b/examples/places-ui-kit/src/components/place-search-webcomponent.tsx deleted file mode 100644 index f22b5cca..00000000 --- a/examples/places-ui-kit/src/components/place-search-webcomponent.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, {useCallback, useEffect, useRef} from 'react'; -import {useMap, useMapsLibrary} from '@vis.gl/react-google-maps'; - -interface Props { - onPlaceSelect: (place: google.maps.places.Place | null) => void; - setPlaces: (markers: google.maps.places.Place[]) => void; - locationId: string | null; - placeType: string | null; -} - -// Custom type definition for the Google Maps Web Components that aren't fully typed in the SDK -type PlaceSearchElement = HTMLElement & { - readonly places: google.maps.places.Place[]; -}; - -type PlaceNearbySearchRequestElement = HTMLElement & { - locationRestriction: {center: google.maps.LatLng; radius: number}; - includedPrimaryTypes?: Array; - maxResultCount: number; -}; - -export const PlaceSearchWebComponent = ({ - onPlaceSelect, - setPlaces, - locationId, - placeType -}: Props) => { - // Load required Google Maps libraries - const placesLib = useMapsLibrary('places'); - const geoLib = useMapsLibrary('geometry'); - - const map = useMap(); - - // Use ref to interact with the DOM Web Component - const placeSearchRef = useRef(null); - const placeNearbySearchRequestRef = - useRef(null); - - // Calculate a circular region based on the map's current bounds - // This is used to restrict the places search to the visible map area - const getContainingCircle = useCallback( - (bounds?: google.maps.LatLngBounds) => { - if (!bounds || !geoLib) return undefined; - - // Calculate diameter between the northeast and southwest corners of the bounds - const diameter = geoLib.spherical.computeDistanceBetween( - bounds.getNorthEast(), - bounds.getSouthWest() - ); - const calculatedRadius = diameter / 2; - - // Cap the radius at 50km to avoid exceeding Google Maps API limits - const cappedRadius = Math.min(calculatedRadius, 50000); - return {center: bounds.getCenter(), radius: cappedRadius}; - }, - [geoLib] - ); - - useEffect(() => { - if ( - !placesLib || - !geoLib || - !placeSearchRef.current || - !placeNearbySearchRequestRef.current || - !map - ) { - return; - } - - const placeNearbySearchRequest = placeNearbySearchRequestRef.current; - - const bounds = map.getBounds(); - const circle = getContainingCircle(bounds); - - if (!circle) return; - - placeNearbySearchRequest.locationRestriction = circle; - placeNearbySearchRequest.includedPrimaryTypes = placeType - ? [placeType] - : undefined; - }, [placesLib, geoLib, map, placeType, getContainingCircle, locationId]); - - // Return the Google Maps Place List Web Component - // This component is rendered as a custom HTML element (Web Component) provided by Google - return ( -
- {/* - gmp-place-list is a Google Maps Platform Web Component that displays a list of places - - 'selectable' enables click-to-select functionality - - When a place is selected, the ongmp-placeselect event is fired - */} - { - onPlaceSelect(event.place); - }} - ongmp-load={(event: {target: PlaceSearchElement}) => { - setPlaces(event.target.places); - }}> - - - -
- ); -}; - -PlaceSearchWebComponent.displayName = 'PlaceSearchWebComponent'; diff --git a/examples/places-ui-kit/src/components/place-search.tsx b/examples/places-ui-kit/src/components/place-search.tsx new file mode 100644 index 00000000..2af45f48 --- /dev/null +++ b/examples/places-ui-kit/src/components/place-search.tsx @@ -0,0 +1,162 @@ +import {useMapsLibrary} from '@vis.gl/react-google-maps'; +import React, {FunctionComponent} from 'react'; +import { + ContentConfig, + ContentItem, + PlaceContentConfig +} from './place-content-config'; +import {Orientation} from './place-details'; + +export const AttributionPosition = { + TOP: 'TOP', + BOTTOM: 'BOTTOM' +} as const; + +export type AttributionPosition = + (typeof AttributionPosition)[keyof typeof AttributionPosition]; + +export type TextSearchOptions = { + textQuery: string; + + evConnectorTypes?: Array; + evMinimumChargingRateKw?: number; + includedType?: string; + isOpenNow?: boolean; + locationBias?: google.maps.places.LocationBias; + locationRestriction?: + | google.maps.LatLngBounds + | google.maps.LatLngBoundsLiteral; + maxResultCount?: number; + minRating?: number; + priceLevels?: Array; + rankPreference?: google.maps.places.SearchByTextRankPreference; + useStrictTypeFiltering?: boolean; +}; + +export type NearbySearchOptions = { + locationRestriction: google.maps.Circle | google.maps.CircleLiteral; + + excludedPrimaryTypes?: Array; + excludedTypes?: Array; + includedPrimaryTypes?: Array; + includedTypes?: Array; + maxResultCount?: number; + rankPreference?: google.maps.places.SearchNearbyRankPreference; +}; + +export type PlaceSearchProps = { + /** + * Classname to pass on to gmp-place-search + */ + className?: string; + /** + * CSS properties to pass on to gmp-place-search + */ + style?: React.CSSProperties; + /** + * Position of attribution, defaults to TOP + */ + attributionPosition?: AttributionPosition; + /** + * Orientation of the list, defaults to vertical + */ + orientation?: Orientation; + /** + * Whether or not text should be truncated + */ + truncationPreferred?: boolean; + /** + * Whether or not the list items should be selectable + * Required for onSelect to work, otherwise optional + */ + selectable?: boolean; + + /** + * Show list of results based on either a nearby or a text search + * text search takes priority if both are provided + */ + textSearch?: TextSearchOptions; + nearbySearch?: NearbySearchOptions; + + /** + * Content config. Defaults to 'standard' + */ + contentConfig: ContentConfig; + /** + * Required only if type is 'custom'. + * The array lists the content elements to display. + */ + customContent?: Array; + + /** + * Fired when the search results are loaded + */ + onLoad?: ({ + target: {places} + }: { + target: {places: Array}; //tbd + }) => void; + /** + * Fired when a place is selected from the list + */ + onSelect?: ({place}: {place: google.maps.places.Place}) => void; + /** + * Fired on error + */ + onError?: (e: Event) => void; +}; + +/** + * Wrapper component for gmp-place-search + * Properties and styling options according to https://developers.google.com/maps/documentation/javascript/reference/places-widget#PlaceSearchElement + */ +export const PlaceSearch: FunctionComponent = props => { + const { + className, + style, + nearbySearch, + textSearch, + contentConfig = ContentConfig.STANDARD, + customContent, + attributionPosition, + orientation = Orientation.VERTICAL, + truncationPreferred, + selectable, + onLoad, + onSelect, + onError + } = props; + + // Load required Google Maps library for places + const placesLibrary = useMapsLibrary('places'); + + if (!placesLibrary || !(nearbySearch || textSearch)) return null; + + return ( + + {textSearch && ( + + )} + {nearbySearch && ( + + )} + + + ); +}; + +PlaceSearch.displayName = 'PlaceSearch'; diff --git a/examples/places-ui-kit/src/components/search-bar.tsx b/examples/places-ui-kit/src/components/search-bar.tsx deleted file mode 100644 index 0860397a..00000000 --- a/examples/places-ui-kit/src/components/search-bar.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, {memo, useMemo} from 'react'; -import {AutocompleteWebComponent} from './autocomplete-webcomponent'; -import {PlaceType} from '../app'; - -interface Props { - setLocationId: (placeId: string | null) => void; - placeType: PlaceType; - setPlaceType: (placeType: PlaceType) => void; -} - -interface PlaceTypeOption { - value: PlaceType; - label: string; -} - -const SearchBarComponent = ({ - placeType, - setPlaceType, - setLocationId -}: Props) => { - const placeTypeOptions: PlaceTypeOption[] = useMemo( - () => [ - {value: 'restaurant', label: 'Restaurants'}, - {value: 'cafe', label: 'Cafes'}, - {value: 'electric_vehicle_charging_station', label: 'EV Charging'} - ], - [] - ); - - const handlePlaceTypeChange = ( - event: React.ChangeEvent - ) => { - setPlaceType(event.target.value as PlaceType); - }; - - return ( -
- - - near - setLocationId(place?.id ?? null)} - /> -
- ); -}; - -SearchBarComponent.displayName = 'SearchBar'; - -export const SearchBar = memo(SearchBarComponent); diff --git a/examples/places-ui-kit/src/place-details-marker.tsx b/examples/places-ui-kit/src/place-details-marker.tsx new file mode 100644 index 00000000..84fef44f --- /dev/null +++ b/examples/places-ui-kit/src/place-details-marker.tsx @@ -0,0 +1,59 @@ +import { + AdvancedMarker, + InfoWindow, + useAdvancedMarkerRef +} from '@vis.gl/react-google-maps'; +import React, {memo, useCallback} from 'react'; + +import {DetailsSize} from './app'; +import {PlaceDetails} from './components/place-details'; + +interface PlaceDetailsMarkerProps { + place: google.maps.places.Place; + selected: boolean; + onClick: (placeId: string | null) => void; + detailsSize: DetailsSize; +} + +const PlaceDetailsMarkerComponent = ({ + place, + selected, + onClick, + detailsSize +}: PlaceDetailsMarkerProps) => { + const [markerRef, marker] = useAdvancedMarkerRef(); + + // Handle marker click to select this place + const handleMarkerClick = useCallback(() => { + onClick(place.id); + }, [onClick, place.id]); + + return ( + <> + + {selected && ( + + {/* + Using the place details component here to specifically have access to the map + */} + + + )} + + ); +}; + +PlaceDetailsMarkerComponent.displayName = 'PlaceDetailsMarker'; + +export default memo(PlaceDetailsMarkerComponent); From 94a10d54696c677af876190e9a29242f0b4ff665 Mon Sep 17 00:00:00 2001 From: Florian Dreyer Date: Thu, 11 Dec 2025 16:44:56 +0100 Subject: [PATCH 2/5] refactor autocomplete --- .../components/basic-place-autocomplete.tsx | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/examples/places-ui-kit/src/components/basic-place-autocomplete.tsx b/examples/places-ui-kit/src/components/basic-place-autocomplete.tsx index ecb17400..b049cf93 100644 --- a/examples/places-ui-kit/src/components/basic-place-autocomplete.tsx +++ b/examples/places-ui-kit/src/components/basic-place-autocomplete.tsx @@ -1,5 +1,6 @@ import {useMapsLibrary} from '@vis.gl/react-google-maps'; import React, { + Children, FunctionComponent, PropsWithChildren, useEffect, @@ -7,6 +8,8 @@ import React, { useState } from 'react'; import {createPortal} from 'react-dom'; +import {usePropBinding} from '../../../../src/hooks/use-prop-binding'; +import {useDomEventListener} from '../../../../src/hooks/use-dom-event-listener'; export type BasicPlaceAutocompleteElementProps = PropsWithChildren<{ /** @@ -75,6 +78,8 @@ export type BasicPlaceAutocompleteElementProps = PropsWithChildren<{ export const BasicPlaceAutocomplete: FunctionComponent< BasicPlaceAutocompleteElementProps > = props => { + const placesLibrary = useMapsLibrary('places'); + const { children, className, @@ -92,15 +97,36 @@ export const BasicPlaceAutocomplete: FunctionComponent< onError } = props; - const placesLibrary = useMapsLibrary('places'); - - const ref = useRef(null); + const numChildren = Children.count(children); const [templateElement, setTemplateElement] = useState(null); + const ref = useRef(null); + + // types have not yet been officially updated so we need to typecast here + // to avoid TS errors + const autocomplete = ref.current as any; + + // bind props to the autocomplete element + usePropBinding(autocomplete, 'includedPrimaryTypes', includedPrimaryTypes); + usePropBinding(autocomplete, 'includedRegionCodes', includedRegionCodes); + usePropBinding(autocomplete, 'locationBias', locationBias); + usePropBinding(autocomplete, 'locationRestriction', locationRestriction); + usePropBinding(autocomplete, 'name', name); + usePropBinding(autocomplete, 'origin', origin); + usePropBinding(autocomplete, 'requestedLanguage', requestedLanguage); + usePropBinding(autocomplete, 'requestedRegion', requestedRegion); + usePropBinding(autocomplete, 'unitSystem', unitSystem); + + // bind events to the autocomplete element + useDomEventListener(autocomplete, 'gmp-select', onSelect); + useDomEventListener(autocomplete, 'gmp-error', onError); + + // create a template element to wrap the children if they exist useEffect(() => { - const autocomplete = ref.current; + if (numChildren === 0) return; + if (!placesLibrary || !autocomplete || !children) return; const template = document.createElement('template'); @@ -115,29 +141,13 @@ export const BasicPlaceAutocomplete: FunctionComponent< autocomplete.removeChild(template); } }; - }, [placesLibrary, children]); + }, [placesLibrary, numChildren, autocomplete]); if (!placesLibrary) return null; return ( - - {templateElement && - children && - createPortal(children, templateElement.content)} + + {templateElement && createPortal(children, templateElement.content)} ); }; From 1442265dd6099aff75a891774fc67611d526aaea Mon Sep 17 00:00:00 2001 From: Florian Dreyer Date: Mon, 15 Dec 2025 15:56:12 +0100 Subject: [PATCH 3/5] refactor place search --- .../src/components/place-search.tsx | 103 ++++++++++++++---- 1 file changed, 82 insertions(+), 21 deletions(-) diff --git a/examples/places-ui-kit/src/components/place-search.tsx b/examples/places-ui-kit/src/components/place-search.tsx index 2af45f48..b1ce825f 100644 --- a/examples/places-ui-kit/src/components/place-search.tsx +++ b/examples/places-ui-kit/src/components/place-search.tsx @@ -1,5 +1,7 @@ import {useMapsLibrary} from '@vis.gl/react-google-maps'; -import React, {FunctionComponent} from 'react'; +import React, {FunctionComponent, useEffect, useState} from 'react'; +import {useDomEventListener} from '../../../../src/hooks/use-dom-event-listener'; +import {usePropBinding} from '../../../../src/hooks/use-prop-binding'; import { ContentConfig, ContentItem, @@ -73,10 +75,10 @@ export type PlaceSearchProps = { /** * Show list of results based on either a nearby or a text search - * text search takes priority if both are provided + * nearby search takes priority if both are provided */ - textSearch?: TextSearchOptions; nearbySearch?: NearbySearchOptions; + textSearch?: TextSearchOptions; /** * Content config. Defaults to 'standard' @@ -130,27 +132,86 @@ export const PlaceSearch: FunctionComponent = props => { // Load required Google Maps library for places const placesLibrary = useMapsLibrary('places'); + const [placeSearch, setPlaceSearch] = + // @ts-ignore + useState(null); + + usePropBinding(placeSearch, 'attributionPosition', attributionPosition); + usePropBinding(placeSearch, 'orientation', orientation); + usePropBinding(placeSearch, 'truncationPreferred', truncationPreferred); + usePropBinding(placeSearch, 'selectable', selectable); + + useDomEventListener(placeSearch, 'gmp-load', onLoad); + useDomEventListener(placeSearch, 'gmp-select', onSelect); + useDomEventListener(placeSearch, 'gmp-error', onError); + + useEffect(() => { + if (!placesLibrary || !textSearch || !placeSearch) return; + + const textSearchRequest = + // @ts-ignore + new placesLibrary.PlaceTextSearchRequestElement(); + + textSearchRequest.textQuery = textSearch.textQuery; + textSearchRequest.evConnectorTypes = textSearch.evConnectorTypes; + textSearchRequest.evMinimumChargingRateKw = + textSearch.evMinimumChargingRateKw; + textSearchRequest.includedType = textSearch.includedType; + textSearchRequest.isOpenNow = textSearch.isOpenNow; + textSearchRequest.locationBias = textSearch.locationBias; + textSearchRequest.locationRestriction = textSearch.locationRestriction; + textSearchRequest.maxResultCount = textSearch.maxResultCount; + textSearchRequest.minRating = textSearch.minRating; + textSearchRequest.priceLevels = textSearch.priceLevels; + textSearchRequest.rankPreference = textSearch.rankPreference; + textSearchRequest.useStrictTypeFiltering = + textSearch.useStrictTypeFiltering; + + placeSearch.appendChild(textSearchRequest); + + return () => { + if (placeSearch.contains(textSearchRequest)) { + placeSearch.removeChild(textSearchRequest); + } + }; + }, [placesLibrary, textSearch, placeSearch]); + + useEffect(() => { + if (!placesLibrary || !nearbySearch || !placeSearch) return; + const { + locationRestriction, + excludedPrimaryTypes, + excludedTypes, + includedPrimaryTypes, + includedTypes, + maxResultCount, + rankPreference + } = nearbySearch; + + const nearbySearchRequest = + // @ts-ignore + new placesLibrary.PlaceNearbySearchRequestElement(); + nearbySearchRequest.locationRestriction = locationRestriction; + nearbySearchRequest.excludedPrimaryTypes = excludedPrimaryTypes; + nearbySearchRequest.excludedTypes = excludedTypes; + nearbySearchRequest.includedPrimaryTypes = includedPrimaryTypes; + nearbySearchRequest.includedTypes = includedTypes; + nearbySearchRequest.maxResultCount = maxResultCount; + nearbySearchRequest.rankPreference = rankPreference; + + placeSearch.appendChild(nearbySearchRequest); + + return () => { + if (placeSearch.contains(nearbySearchRequest)) { + placeSearch.removeChild(nearbySearchRequest); + } + }; + }, [placesLibrary, nearbySearch, placeSearch]); + if (!placesLibrary || !(nearbySearch || textSearch)) return null; return ( - - {textSearch && ( - - )} - {nearbySearch && ( - - )} + Date: Mon, 15 Dec 2025 15:56:54 +0100 Subject: [PATCH 4/5] cleanup --- examples/places-ui-kit/src/place-details-marker.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/places-ui-kit/src/place-details-marker.tsx b/examples/places-ui-kit/src/place-details-marker.tsx index 84fef44f..98963aea 100644 --- a/examples/places-ui-kit/src/place-details-marker.tsx +++ b/examples/places-ui-kit/src/place-details-marker.tsx @@ -46,8 +46,10 @@ const PlaceDetailsMarkerComponent = ({ */} + placeId={place.id} + /> )} From 9f0daa48b9bb4880e9cf79928c15e715d772f776 Mon Sep 17 00:00:00 2001 From: Florian Dreyer Date: Tue, 16 Dec 2025 12:38:59 +0100 Subject: [PATCH 5/5] refactor config & details elements --- .../src/components/place-content-config.tsx | 78 ++++++++++++++++--- .../src/components/place-details.tsx | 44 +++++++---- .../src/components/place-search.tsx | 2 +- .../src/place-details-marker.tsx | 23 +++++- 4 files changed, 119 insertions(+), 28 deletions(-) diff --git a/examples/places-ui-kit/src/components/place-content-config.tsx b/examples/places-ui-kit/src/components/place-content-config.tsx index 38f4a26a..b6fe3c33 100644 --- a/examples/places-ui-kit/src/components/place-content-config.tsx +++ b/examples/places-ui-kit/src/components/place-content-config.tsx @@ -1,4 +1,4 @@ -import React, {FunctionComponent} from 'react'; +import React, {FunctionComponent, useEffect, useRef} from 'react'; export const ContentConfig = { STANDARD: 'standard', @@ -30,21 +30,25 @@ export type PlaceDetailsContentItem = | 'type-specific-highlights' | 'website'; +type AttributionProps = { + lightSchemeColor?: google.maps.places.AttributionColor | null; + darkSchemeColor?: google.maps.places.AttributionColor | null; +}; + +type MediaProps = { + lightboxPreferred?: boolean; + // FIXME: update to google.maps.places.MediaSize + preferredSize?: 'SMALL' | 'MEDIUM' | 'LARGE'; +}; + type AttributionContentItem = { attribute: 'attribution'; - options?: { - lightSchemeColor?: google.maps.places.AttributionColor | null; - darkSchemeColor?: google.maps.places.AttributionColor | null; - }; + options?: AttributionProps; }; type MediaContentItem = { attribute: 'media'; - options?: { - lightboxPreferred?: boolean; - // FIXME: update to google.maps.places.MediaSize - preferredSize?: 'SMALL' | 'MEDIUM' | 'LARGE'; - }; + options?: MediaProps; }; type DefaultContentItem = { @@ -66,6 +70,38 @@ export type PlaceContentConfigProps = { customContent?: Array; }; +const AttributionWrapper: FunctionComponent = ({ + lightSchemeColor, + darkSchemeColor +}) => { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.lightSchemeColor = lightSchemeColor; + ref.current.darkSchemeColor = darkSchemeColor; + } + }, [lightSchemeColor, darkSchemeColor]); + + return ; +}; + +const MediaWrapper: FunctionComponent = ({ + lightboxPreferred, + preferredSize +}) => { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.lightboxPreferred = lightboxPreferred; + ref.current.preferredSize = preferredSize; + } + }, [lightboxPreferred, preferredSize]); + + return ; +}; + /** * Maps a content item string to its corresponding Google Maps Web Component JSX. */ @@ -76,9 +112,27 @@ const renderContentItem = (itemConfig: ContentItem, key: number) => { case 'accessible-entrance-icon': return ; case 'attribution': - return ; + const {lightSchemeColor, darkSchemeColor} = + (itemConfig.options as AttributionContentItem['options']) || {}; + + return ( + + ); case 'media': - return ; + const {lightboxPreferred, preferredSize} = + (itemConfig.options as MediaContentItem['options']) || {}; + + return ( + + ); case 'open-now-status': return ; case 'price': diff --git a/examples/places-ui-kit/src/components/place-details.tsx b/examples/places-ui-kit/src/components/place-details.tsx index 68161e5b..63ce9261 100644 --- a/examples/places-ui-kit/src/components/place-details.tsx +++ b/examples/places-ui-kit/src/components/place-details.tsx @@ -1,10 +1,12 @@ import {useMapsLibrary} from '@vis.gl/react-google-maps'; -import React, {FunctionComponent} from 'react'; +import React, {FunctionComponent, useRef, useState} from 'react'; import { ContentConfig, ContentItem, PlaceContentConfig } from './place-content-config'; +import {useDomEventListener} from '../../../../src/hooks/use-dom-event-listener'; +import {usePropBinding} from '../../../../src/hooks/use-prop-binding'; export const Orientation = { HORIZONTAL: 'HORIZONTAL', @@ -57,7 +59,7 @@ export type PlaceDetailsProps = { /** * Orientation of the displayed information. Defaults to vertical. */ - orientation?: Orientation; + orientation?: google.maps.places.PlaceDetailsOrientation; /** * Whether or not text should be truncated. */ @@ -83,6 +85,9 @@ export type PlaceDetailsProps = { * and https://developers.google.com/maps/documentation/javascript/reference/places-widget#PlaceDetailsCompactElement */ export const PlaceDetails: FunctionComponent = props => { + // Load required Google Maps library for places + const placesLibrary = useMapsLibrary('places'); + const { className, style, @@ -91,25 +96,37 @@ export const PlaceDetails: FunctionComponent = props => { location, contentConfig = ContentConfig.STANDARD, customContent, - orientation = Orientation.VERTICAL, - truncationPreferred, + orientation, + truncationPreferred = false, onLoad, onError } = props; - // Load required Google Maps library for places - const placesLibrary = useMapsLibrary('places'); + const [compactDetailsElement, setCompactDetailsElement] = + useState(null); + const [standardDetailsElement, setStandardDetailsElement] = + useState(null); + const detailsElement = compact + ? compactDetailsElement + : standardDetailsElement; + + usePropBinding(compactDetailsElement, 'orientation', orientation); + usePropBinding( + compactDetailsElement, + 'truncationPreferred', + truncationPreferred + ); + + useDomEventListener(detailsElement, 'gmp-load', onLoad); + useDomEventListener(detailsElement, 'gmp-error', onError); if (!placesLibrary || !(placeId || location)) return null; return compact ? ( + style={style}> = props => { ) : ( + style={style}> = props => { if (!placesLibrary || !textSearch || !placeSearch) return; const textSearchRequest = - // @ts-ignore + // @ts-ignore types are not up to date here new placesLibrary.PlaceTextSearchRequestElement(); textSearchRequest.textQuery = textSearch.textQuery; diff --git a/examples/places-ui-kit/src/place-details-marker.tsx b/examples/places-ui-kit/src/place-details-marker.tsx index 98963aea..d59da9b2 100644 --- a/examples/places-ui-kit/src/place-details-marker.tsx +++ b/examples/places-ui-kit/src/place-details-marker.tsx @@ -47,7 +47,28 @@ const PlaceDetailsMarkerComponent = ({