mui-places-autocomplete
Version:
Material-UI React component that provides suggestions/autocompletes places using the Google Places API
290 lines (262 loc) • 12.9 kB
JSX
import React from 'react'
import PropTypes from 'prop-types'
import Grow from '@material-ui/core/Grow'
import MenuList from '@material-ui/core/MenuList'
import MenuItem from '@material-ui/core/MenuItem'
import Paper from '@material-ui/core/Paper'
import TextField from '@material-ui/core/TextField'
import Downshift from 'downshift'
import { Manager, Target, Popper } from 'react-popper'
import match from 'autosuggest-highlight/match'
import parse from 'autosuggest-highlight/parse'
import googleLogo from './images/google-logo-on-white-bg.png'
export default class MUIPlacesAutocomplete extends React.Component {
// Renders the container that will hold the suggestions and defers to other methods to render the
// suggestions themselves. This method should only be called if you do indeed plan on rendering
// the suggestions. In our case this is when 'isOpen' Downshift render prop is 'true'. Thats
// because the methods that are used to render the suggestions invoke the 'getItemProps' prop
// getter from Downshift which is an impure function. In otherwords even if you don't render the
// suggestions container Downshift will still think we are rendering suggestions.
//
// The 'downshiftRenderProps' argument expects an object of props that Downshift passes to the
// function which is set as the value of the 'render' prop on the <Downshift> component. Currently
// the following Downshift render props are expected on the value provided to the
// 'downshiftRenderProps' argument:
// * getItemProps - function that returns the props that ought to be applied to menu item elements
// that are rendered
// * inputValue - current value of the controlled <input> element
// * highlightedIndex - index of the currently highlighted menu item elements that have been
// rendered
static renderSuggestionsContainer(suggestions, downshiftRenderProps) {
// Return null here if there are no suggestions to render. If we don't we will show a little box
// that is empty and popped over the render target. This handles the case where a suggestion is
// selected, the input value is updated, and then the user deletes the input value. This
// behavior is attributed to setting the suggestions to the empty array in the
// 'onInputValueChange' method.
//
// Be sure we return null here before we render any of our suggestions lest we invoke the impure
// 'getItemProps' function.
if (suggestions.length === 0) {
return null
}
// The autocomplete service can return multiple of the same predictions. This can sometimes be
// seen after someone selects a suggestion and starts to delete/backspace the input value which
// contains their selected suggestion. Here we will ensure uniqueness amongst suggestions using
// an ES6 Map so that we don't get duplicate key errors when we render our suggestions.
const uniqueSuggestions =
new Map(suggestions.map(suggestion => [suggestion.description, suggestion]))
const renderedSuggestions =
MUIPlacesAutocomplete.renderSuggestions([...uniqueSuggestions.values()], downshiftRenderProps)
// On the <Popper> component we enable the 'inner' modifier. This is needed as Popper JS will
// try to change the position of the popover depending on if it deems the popover is in or out
// of view. The result of enabling the 'inner' modifier means that the position of the popover
// won't change at all regardless of if the popover is in or out of view.
//
// Typically the <Popper> receives actual nodes for its children but in our case we opted to
// provided a function that creats a <div> with styles applied to it at the top-level. This
// <div> with the styles applied is to account for issues that arise when testing. Without it we
// will get the following warnings: NaN is an invalid value for the top/left css style property.
// This is because the DOM provider/implementation we use when testing (jsdom) doesn't return
// values for bounding client rect.
return (
<Popper
placement="top-start"
modifiers={({ inner: { enabled: true } })}
style={{ left: 0, right: 0, zIndex: 1 }}
>
{({ popperProps, restProps }) => (
<div
{...popperProps}
{...restProps}
style={{
...popperProps.style,
top: popperProps.style.top || 0,
left: popperProps.style.left || 0,
...restProps.style,
}}
>
<Grow in style={{ transformOrigin: '0 0 0' }}>
<Paper>
<MenuList>
{renderedSuggestions}
{renderedSuggestions.length > 0
? (
<div style={{ display: 'flex' }}>
<span style={{ flex: 1 }} />
<img src={googleLogo} alt="" />
</div>
)
: null}
</MenuList>
</Paper>
</Grow>
</div>
)}
</Popper>
)
}
// Helper method to be called by 'renderSuggestionsContainer'. Returns list of rendered
// suggestions.
static renderSuggestions(suggestions, { getItemProps, inputValue, highlightedIndex }) {
return suggestions.map((suggestion, index) =>
MUIPlacesAutocomplete.renderSuggestion(
suggestion,
{ getItemProps, inputValue, isHighlighted: index === highlightedIndex },
))
}
// Helper method to be called by 'renderSuggestions'. Renders suggestions where they are
// highlighted based on the parts of the suggestion that match the query the user entered. This is
// inline with the Google Maps webapp at the time of writing. This behavior is opposite of how the
// Google Search bar/component/element works though.
static renderSuggestion(suggestion, { getItemProps, inputValue, isHighlighted }) {
const { description } = suggestion
// Calculate the chars to highlight in the suggestion 'description' based on the query
// ('inputValue') that the user provided us. An array is returned and if any chars ought to be
// highlighted the array will contain a pair ([a, b]) which denote the indexes of chars to
// highlight (i.e. text.slice(a, b)).
const matches = match(description, inputValue)
// Break up the suggestion 'description' based on the parts that matched. An array is returned
// of parts where each one has an indication of if it ought to be highlighted or not.
const parts = parse(description, matches)
return (
<MenuItem
{...getItemProps({ item: suggestion })}
key={description}
selected={isHighlighted}
component="div"
>
<div>
{parts.map((part, index) => {
if (part.highlight) {
// Since we are further breaking down an array there is nothing unique about the
// elements in the resulting array so we can disable the react/no-array-index-key
// ESLint rule when rendering our suggestion
// eslint-disable-next-line react/no-array-index-key
return <strong key={index} style={{ fontWeight: 500 }}>{part.text}</strong>
}
// Since we are further breaking down an array there is nothing unique about the
// elements in the resulting array so we can disable the react/no-array-index-key
// ESLint rule when rendering our suggestion
// eslint-disable-next-line react/no-array-index-key
return <span key={index} style={{ fontWeight: 300 }}>{part.text}</span>
})}
</div>
</MenuItem>
)
}
constructor() {
super()
// Control the <input> element/<Autosuggest> component and make this React component the source
// of truth for their state.
this.state = {
suggestions: [],
}
this.onInputValueChange = this.onInputValueChange.bind(this)
this.onSuggestionSelected = this.onSuggestionSelected.bind(this)
this.renderAutocomplete = this.renderAutocomplete.bind(this)
}
componentDidMount() {
// After the component is mounted it is safe to create a new instance of the autocomplete
// service client. That's because at this point the Google Maps JavaScript API has been loaded.
// Also if we do it before the component is mounted (i.e. in 'componentWillMount()') we won't be
// safe to render on the server (SSR) as the 'window' object isn't available.
this.autocompleteService = new window.google.maps.places.AutocompleteService()
}
// This function is called whenever Downshift detects that the input value, well, has changed.
// Although we only use a single argument in our function signature Downshift documents the
// function signature as:
// onInputValueChange(inputValue: string, stateAndHelpers: object)
onInputValueChange(inputValue) {
// If the inputs value is empty we can return as we will get an error if we provide the empty
// string when we perform a search. Set our suggestions to empty here as well so we don't render
// the old suggestions.
if (inputValue === '') {
this.setState({ suggestions: [] })
return
}
const { createAutocompleteRequest } = this.props
this.autocompleteService.getPlacePredictions(
createAutocompleteRequest(inputValue),
(predictions, serviceStatus) => {
// If the response doesn't contain a valid result then set our state as if no suggestions
// were returned
if (serviceStatus !== window.google.maps.places.PlacesServiceStatus.OK) {
this.setState({ suggestions: [] })
return
}
this.setState({ suggestions: predictions })
},
)
}
// This function is called whenever Downshift detects that a rendered suggestion has been
// selected. Although we only use a single argument in our function signature Downshift documents
// the function signature as:
// onSelect(selectedItem: any, stateAndHelpers: object)
onSuggestionSelected(suggestion) {
const { onSuggestionSelected } = this.props
if (onSuggestionSelected) {
onSuggestionSelected(suggestion)
}
}
renderAutocomplete({
getInputProps,
getItemProps,
isOpen,
inputValue,
highlightedIndex,
}) {
const { suggestions } = this.state
const { renderTarget, textFieldProps } = this.props
// We set the value of 'tag' on the <Manager> component to false to allow the rendering of
// children instead of a specific DOM element.
//
// We only want to render our suggestions container if Downshift says we are open AND there are
// suggestions to actually render. There may not be suggestions yet due to the async nature of
// requesting them from the Google Maps/Places service.
//
// Provide an 'id' to the input props (see <TextField>) to accommodate SSR. If we don't then we
// will see checksum errors with the 'id' prop of the <input> element not matching what was
// rendered on the server vs. what was rendered on the client after rehydration due to automatic
// 'id' prop generation by <Downshift>.
return (
<div>
<Manager tag={false}>
<TextField {...getInputProps({ id: 'mui-places-autocomplete-input', ...textFieldProps })} />
<Target>{renderTarget()}</Target>
{isOpen ? MUIPlacesAutocomplete.renderSuggestionsContainer(
suggestions,
{ getItemProps, inputValue, highlightedIndex },
)
: null}
</Manager>
</div>
)
}
render() {
// Check to see if a consumer would like to exert control on the <input> elements state. If so
// we pass it to the <Downshift> component as the 'inputValue' prop to provide control of the
// <input> elements state to the consumer.
const controlProps = this.props.textFieldProps && this.props.textFieldProps.value ?
{ inputValue: this.props.textFieldProps.value } :
{ }
return (
<Downshift
onSelect={this.onSuggestionSelected}
onInputValueChange={this.onInputValueChange}
itemToString={suggestion => (suggestion ? suggestion.description : '')}
render={this.renderAutocomplete}
{...controlProps}
/>
)
}
}
MUIPlacesAutocomplete.propTypes = {
onSuggestionSelected: PropTypes.func.isRequired,
renderTarget: PropTypes.func.isRequired,
createAutocompleteRequest: PropTypes.func,
textFieldProps: PropTypes.object,
}
MUIPlacesAutocomplete.defaultProps = {
createAutocompleteRequest: inputValue => ({ input: inputValue }),
textFieldProps: { autoFocus: false, placeholder: 'Search for a place', fullWidth: true },
}