From 5ce77c0c849232e0817c3354733ef6728661bd6f Mon Sep 17 00:00:00 2001 From: Olivier Tassinari Date: Tue, 10 Dec 2024 00:04:31 +0100 Subject: [PATCH] [autocomplete][docs] Improve Google Maps search example --- .../components/autocomplete/GoogleMaps.js | 150 +++++++++------ .../components/autocomplete/GoogleMaps.tsx | 180 +++++++++++------- .../components/autocomplete/autocomplete.md | 6 +- packages/mui-docs/src/Ad/AdCarbon.tsx | 5 + packages/mui-docs/src/utils/loadScript.ts | 7 +- 5 files changed, 213 insertions(+), 135 deletions(-) diff --git a/docs/data/material/components/autocomplete/GoogleMaps.js b/docs/data/material/components/autocomplete/GoogleMaps.js index 0287e024d5de19..7dd26015ac2c89 100644 --- a/docs/data/material/components/autocomplete/GoogleMaps.js +++ b/docs/data/material/components/autocomplete/GoogleMaps.js @@ -3,91 +3,124 @@ import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import Autocomplete from '@mui/material/Autocomplete'; import LocationOnIcon from '@mui/icons-material/LocationOn'; -import Grid from '@mui/material/Grid'; +import Grid2 from '@mui/material/Grid2'; import Typography from '@mui/material/Typography'; import parse from 'autosuggest-highlight/parse'; -import { debounce } from '@mui/material/utils'; +import throttle from 'lodash/throttle'; // This key was created specifically for the demo in mui.com. // You need to create a new one for your application. const GOOGLE_MAPS_API_KEY = 'AIzaSyC3aviU6KHXAjoSnxcw6qbOhjnFctbxPkE'; -function loadScript(src, position, id) { - if (!position) { - return; - } +const useEnhancedEffect = + typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; +function loadScript(src, position) { const script = document.createElement('script'); script.setAttribute('async', ''); - script.setAttribute('id', id); script.src = src; position.appendChild(script); + return script; } -const autocompleteService = { current: null }; +const fetch = throttle(async (request, callback) => { + const { suggestions } = + await window.google.maps.places.AutocompleteSuggestion.fetchAutocompleteSuggestions( + request, + ); + + callback( + suggestions.map((suggestion) => { + const place = suggestion.placePrediction; + // Map to the old AutocompleteService.getPlacePredictions format + // https://developers.google.com/maps/documentation/javascript/places-migration-autocomplete + return { + description: place.text.text, + structured_formatting: { + main_text: place.mainText.text, + main_text_matched_substrings: place.mainText.matches.map((match) => ({ + offset: match.startOffset, + length: match.endOffset - match.startOffset, + })), + secondary_text: place.secondaryText?.text, + }, + }; + }), + ); +}, 300); + +const emptyOptions = []; +let sessionToken; export default function GoogleMaps() { const [value, setValue] = React.useState(null); const [inputValue, setInputValue] = React.useState(''); - const [options, setOptions] = React.useState([]); - const loaded = React.useRef(false); + const [options, setOptions] = React.useState(emptyOptions); + const callbackId = React.useId().replace(/:/g, ''); + const [loaded, setLoaded] = React.useState(false); - if (typeof window !== 'undefined' && !loaded.current) { + if (typeof window !== 'undefined') { if (!document.querySelector('#google-maps')) { - loadScript( - `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places`, + const GOOGLE_NAMESPACE = '_google_callback'; + const globalContext = + window[GOOGLE_NAMESPACE] || (window[GOOGLE_NAMESPACE] = {}); + globalContext[callbackId] = () => { + setLoaded(true); + }; + + const script = loadScript( + `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places&loading=async&callback=${GOOGLE_NAMESPACE}.${callbackId}`, document.querySelector('head'), - 'google-maps', ); + script.id = 'google-maps'; + } else if (window.google && !loaded) { + setLoaded(true); } - - loaded.current = true; } - const fetch = React.useMemo( - () => - debounce((request, callback) => { - autocompleteService.current.getPlacePredictions(request, callback); - }, 400), - [], - ); - - React.useEffect(() => { - let active = true; - - if (!autocompleteService.current && window.google) { - autocompleteService.current = - new window.google.maps.places.AutocompleteService(); - } - if (!autocompleteService.current) { + useEnhancedEffect(() => { + if (!loaded) { return undefined; } if (inputValue === '') { - setOptions(value ? [value] : []); + setOptions(value ? [value] : emptyOptions); return undefined; } - fetch({ input: inputValue }, (results) => { - if (active) { - let newOptions = []; + // Allow to resolve the out of order request resolution. + let active = true; - if (value) { - newOptions = [value]; - } + if (!sessionToken) { + sessionToken = new window.google.maps.places.AutocompleteSessionToken(); + } - if (results) { - newOptions = [...newOptions, ...results]; - } + fetch({ input: inputValue, sessionToken }, (results) => { + if (!active) { + return; + } - setOptions(newOptions); + let newOptions = []; + + if (results) { + newOptions = results; + + if (value) { + newOptions = [ + value, + ...results.filter((result) => result.description !== value.description), + ]; + } + } else if (value) { + newOptions = [value]; } + setOptions(newOptions); }); return () => { active = false; }; - }, [value, inputValue, fetch]); + }, [value, inputValue, loaded]); return ( { const { key, ...optionProps } = props; - const matches = - option.structured_formatting.main_text_matched_substrings || []; + const matches = option.structured_formatting.main_text_matched_substrings; const parts = parse( option.structured_formatting.main_text, @@ -123,25 +155,31 @@ export default function GoogleMaps() { ); return (
  • - - + + - - + + {parts.map((part, index) => ( {part.text} ))} - - {option.structured_formatting.secondary_text} - - - + {option.structured_formatting.secondary_text ? ( + + {option.structured_formatting.secondary_text} + + ) : null} + +
  • ); }} diff --git a/docs/data/material/components/autocomplete/GoogleMaps.tsx b/docs/data/material/components/autocomplete/GoogleMaps.tsx index b52bd46bd7f7ad..2d94e09bff7b0c 100644 --- a/docs/data/material/components/autocomplete/GoogleMaps.tsx +++ b/docs/data/material/components/autocomplete/GoogleMaps.tsx @@ -3,115 +3,150 @@ import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import Autocomplete from '@mui/material/Autocomplete'; import LocationOnIcon from '@mui/icons-material/LocationOn'; -import Grid from '@mui/material/Grid'; +import Grid2 from '@mui/material/Grid2'; import Typography from '@mui/material/Typography'; import parse from 'autosuggest-highlight/parse'; -import { debounce } from '@mui/material/utils'; +import throttle from 'lodash/throttle'; // This key was created specifically for the demo in mui.com. // You need to create a new one for your application. const GOOGLE_MAPS_API_KEY = 'AIzaSyC3aviU6KHXAjoSnxcw6qbOhjnFctbxPkE'; -function loadScript(src: string, position: HTMLElement | null, id: string) { - if (!position) { - return; - } +const useEnhancedEffect = + typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; +function loadScript(src: string, position: HTMLElement) { const script = document.createElement('script'); script.setAttribute('async', ''); - script.setAttribute('id', id); script.src = src; position.appendChild(script); + return script; } -const autocompleteService = { current: null }; - interface MainTextMatchedSubstrings { offset: number; length: number; } interface StructuredFormatting { main_text: string; - secondary_text: string; - main_text_matched_substrings?: readonly MainTextMatchedSubstrings[]; + main_text_matched_substrings: readonly MainTextMatchedSubstrings[]; + secondary_text?: string; } interface PlaceType { description: string; structured_formatting: StructuredFormatting; } +const fetch = throttle( + async ( + request: { input: string; sessionToken: any }, + callback: (results?: readonly PlaceType[]) => void, + ) => { + const { suggestions } = await ( + window as any + ).google.maps.places.AutocompleteSuggestion.fetchAutocompleteSuggestions( + request, + ); + + callback( + suggestions.map((suggestion: any) => { + const place = suggestion.placePrediction; + // Map to the old AutocompleteService.getPlacePredictions format + // https://developers.google.com/maps/documentation/javascript/places-migration-autocomplete + return { + description: place.text.text, + structured_formatting: { + main_text: place.mainText.text, + main_text_matched_substrings: place.mainText.matches.map( + (match: any) => ({ + offset: match.startOffset, + length: match.endOffset - match.startOffset, + }), + ), + secondary_text: place.secondaryText?.text, + }, + }; + }), + ); + }, + 300, +); + +const emptyOptions = [] as any; +let sessionToken: any; + export default function GoogleMaps() { const [value, setValue] = React.useState(null); const [inputValue, setInputValue] = React.useState(''); - const [options, setOptions] = React.useState([]); - const loaded = React.useRef(false); + const [options, setOptions] = React.useState(emptyOptions); + const callbackId = React.useId().replace(/:/g, ''); + const [loaded, setLoaded] = React.useState(false); - if (typeof window !== 'undefined' && !loaded.current) { + if (typeof window !== 'undefined') { if (!document.querySelector('#google-maps')) { - loadScript( - `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places`, - document.querySelector('head'), - 'google-maps', + const GOOGLE_NAMESPACE = '_google_callback'; + const globalContext = + // @ts-ignore + window[GOOGLE_NAMESPACE] || (window[GOOGLE_NAMESPACE] = {}); + globalContext[callbackId] = () => { + setLoaded(true); + }; + + const script = loadScript( + `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places&loading=async&callback=${GOOGLE_NAMESPACE}.${callbackId}`, + document.querySelector('head')!, ); + script.id = 'google-maps'; + } else if ((window as any).google && !loaded) { + setLoaded(true); } - - loaded.current = true; } - const fetch = React.useMemo( - () => - debounce( - ( - request: { input: string }, - callback: (results?: readonly PlaceType[]) => void, - ) => { - (autocompleteService.current as any).getPlacePredictions( - request, - callback, - ); - }, - 400, - ), - [], - ); - - React.useEffect(() => { - let active = true; - - if (!autocompleteService.current && (window as any).google) { - autocompleteService.current = new ( - window as any - ).google.maps.places.AutocompleteService(); - } - if (!autocompleteService.current) { + useEnhancedEffect(() => { + if (!loaded) { return undefined; } if (inputValue === '') { - setOptions(value ? [value] : []); + setOptions(value ? [value] : emptyOptions); return undefined; } - fetch({ input: inputValue }, (results?: readonly PlaceType[]) => { - if (active) { - let newOptions: readonly PlaceType[] = []; + // Allow to resolve the out of order request resolution. + let active = true; - if (value) { - newOptions = [value]; - } + if (!sessionToken) { + sessionToken = new ( + window as any + ).google.maps.places.AutocompleteSessionToken(); + } - if (results) { - newOptions = [...newOptions, ...results]; - } + fetch({ input: inputValue, sessionToken }, (results?: readonly PlaceType[]) => { + if (!active) { + return; + } + + let newOptions: readonly PlaceType[] = []; + + if (results) { + newOptions = results; - setOptions(newOptions); + if (value) { + newOptions = [ + value, + ...results.filter((result) => result.description !== value.description), + ]; + } + } else if (value) { + newOptions = [value]; } + setOptions(newOptions); }); return () => { active = false; }; - }, [value, inputValue, fetch]); + }, [value, inputValue, loaded]); return ( { const { key, ...optionProps } = props; - const matches = - option.structured_formatting.main_text_matched_substrings || []; + const matches = option.structured_formatting.main_text_matched_substrings; const parts = parse( option.structured_formatting.main_text, @@ -147,25 +181,31 @@ export default function GoogleMaps() { ); return (
  • - - + + - - + + {parts.map((part, index) => ( {part.text} ))} - - {option.structured_formatting.secondary_text} - - - + {option.structured_formatting.secondary_text ? ( + + {option.structured_formatting.secondary_text} + + ) : null} + +
  • ); }} diff --git a/docs/data/material/components/autocomplete/autocomplete.md b/docs/data/material/components/autocomplete/autocomplete.md index 0f7b7c214ce503..09ec3ffbf059bc 100644 --- a/docs/data/material/components/autocomplete/autocomplete.md +++ b/docs/data/material/components/autocomplete/autocomplete.md @@ -215,12 +215,10 @@ overriding the `filterOptions` prop: A customized UI for Google Maps Places Autocomplete. For this demo, we need to load the [Google Maps JavaScript](https://developers.google.com/maps/documentation/javascript/overview) and [Google Places](https://developers.google.com/maps/documentation/places/web-service/overview) API. -:::info -The following demo relies on [autosuggest-highlight](https://github.com/moroshko/autosuggest-highlight), a small (1 kB) utility for highlighting text in autosuggest and autocomplete components. -::: - {{"demo": "GoogleMaps.js"}} +The demo relies on [autosuggest-highlight](https://github.com/moroshko/autosuggest-highlight), a small (1 kB) utility for highlighting text in autosuggest and autocomplete components. + :::error Before you can start using the Google Maps JavaScript API and Places API, you need to get your own [API key](https://developers.google.com/maps/documentation/javascript/get-api-key). ::: diff --git a/packages/mui-docs/src/Ad/AdCarbon.tsx b/packages/mui-docs/src/Ad/AdCarbon.tsx index 73727ae49dd714..f0add7044eb790 100644 --- a/packages/mui-docs/src/Ad/AdCarbon.tsx +++ b/packages/mui-docs/src/Ad/AdCarbon.tsx @@ -47,6 +47,11 @@ function AdCarbonImage() { // // To solve the issue, for example StrictModel double effect execution, we debounce the load action. const load = setTimeout(() => { + // The DOM node could have unmounted at this point. + if (!ref.current) { + return; + } + const script = loadScript( 'https://cdn.carbonads.com/carbon.js?serve=CKYIL27L&placement=material-uicom', ref.current, diff --git a/packages/mui-docs/src/utils/loadScript.ts b/packages/mui-docs/src/utils/loadScript.ts index 5c245efc2d03d9..4f9e811aae2067 100644 --- a/packages/mui-docs/src/utils/loadScript.ts +++ b/packages/mui-docs/src/utils/loadScript.ts @@ -1,10 +1,7 @@ -export default function loadScript(src: string, position: HTMLElement | null) { +export default function loadScript(src: string, position: HTMLElement) { const script = document.createElement('script'); script.setAttribute('async', ''); script.src = src; - if (position) { - position.appendChild(script); - } - + position.appendChild(script); return script; }