UNPKG

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
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 }, }