mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-28 20:53:24 +10:00
Separate LocationAutocompleteInput with Google Maps implementation
This commit is contained in:
parent
488feaa36f
commit
1cb05da531
3 changed files with 512 additions and 506 deletions
|
|
@ -1,511 +1,8 @@
|
|||
import React, { Component } from 'react';
|
||||
import { any, arrayOf, bool, func, number, object, shape, string, oneOfType } from 'prop-types';
|
||||
import { func, object, shape, string } from 'prop-types';
|
||||
import { Field } from 'react-final-form';
|
||||
import debounce from 'lodash/debounce';
|
||||
import classNames from 'classnames';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { getPlacePredictions, getPlaceDetails } from '../../util/googleMaps';
|
||||
import { ValidationError } from '../../components';
|
||||
|
||||
import css from './LocationAutocompleteInput.css';
|
||||
|
||||
const DEBOUNCE_WAIT_TIME = 200;
|
||||
const KEY_CODE_ARROW_UP = 38;
|
||||
const KEY_CODE_ARROW_DOWN = 40;
|
||||
const KEY_CODE_ENTER = 13;
|
||||
const KEY_CODE_TAB = 9;
|
||||
const KEY_CODE_ESC = 27;
|
||||
const DIRECTION_UP = 'up';
|
||||
const DIRECTION_DOWN = 'down';
|
||||
const TOUCH_TAP_RADIUS = 5; // Movement within 5px from touch start is considered a tap
|
||||
|
||||
const Icon = () => (
|
||||
<svg
|
||||
className={css.iconSvg}
|
||||
width="21"
|
||||
height="22"
|
||||
viewBox="0 0 21 22"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
className={css.iconSvgGroup}
|
||||
transform="matrix(-1 0 0 1 20 1)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M13 14l5.241 5.241" />
|
||||
<circle cx="7.5" cy="7.5" r="7.5" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Touch devices need to be able to distinguish touches for scrolling and touches to tap
|
||||
const getTouchCoordinates = nativeEvent => {
|
||||
const touch = nativeEvent && nativeEvent.changedTouches ? nativeEvent.changedTouches[0] : null;
|
||||
return touch ? { x: touch.screenX, y: touch.screenY } : null;
|
||||
};
|
||||
|
||||
// Renders the autocompletion prediction results in a list
|
||||
const LocationPredictionsList = props => {
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
predictions,
|
||||
highlightedIndex,
|
||||
onSelectStart,
|
||||
onSelectMove,
|
||||
onSelectEnd,
|
||||
} = props;
|
||||
if (predictions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
const item = (prediction, index) => {
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={isHighlighted ? css.highlighted : null}
|
||||
key={prediction.id}
|
||||
onTouchStart={e => onSelectStart(getTouchCoordinates(e.nativeEvent))}
|
||||
onMouseDown={() => onSelectStart()}
|
||||
onTouchMove={e => onSelectMove(getTouchCoordinates(e.nativeEvent))}
|
||||
onTouchEnd={() => onSelectEnd(index)}
|
||||
onMouseUp={() => onSelectEnd(index)}
|
||||
>
|
||||
{prediction.description}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
|
||||
const classes = classNames(rootClassName || css.predictionsRoot, className);
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<ul className={css.predictions}>{predictions.map(item)}</ul>
|
||||
<div className={css.poweredByGoogle} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LocationPredictionsList.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
highlightedIndex: null,
|
||||
};
|
||||
|
||||
LocationPredictionsList.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
predictions: arrayOf(
|
||||
shape({
|
||||
id: string.isRequired,
|
||||
description: string.isRequired,
|
||||
place_id: string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
highlightedIndex: number,
|
||||
onSelectStart: func.isRequired,
|
||||
onSelectMove: func.isRequired,
|
||||
onSelectEnd: func.isRequired,
|
||||
};
|
||||
|
||||
// Get the current value with defaults from the given
|
||||
// LocationAutocompleteInput props.
|
||||
const currentValue = props => {
|
||||
const value = props.input.value || {};
|
||||
const { search = '', predictions = [], selectedPlaceId = null, selectedPlace = null } = value;
|
||||
return { search, predictions, selectedPlaceId, selectedPlace };
|
||||
};
|
||||
|
||||
/*
|
||||
Location auto completion input component
|
||||
|
||||
This component can work as the `component` prop to Final Form's
|
||||
<Field /> component. It takes a custom input value shape, and
|
||||
controls the onChange callback that is called with the input value.
|
||||
|
||||
The component works by listening to the underlying input component
|
||||
and calling the Google Maps Places API for predictions. When the
|
||||
predictions arrive, those are passed to Final Form in the onChange
|
||||
callback.
|
||||
|
||||
See the LocationAutocompleteInput.example.js file for a usage
|
||||
example within a form.
|
||||
*/
|
||||
class LocationAutocompleteInput extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inputHasFocus: false,
|
||||
selectionInProgress: false,
|
||||
touchStartedFrom: null,
|
||||
highlightedIndex: -1, // -1 means no highlight
|
||||
};
|
||||
|
||||
// Ref to the input element.
|
||||
this.input = null;
|
||||
|
||||
// Current sessionToken used to combine autocomplete calls with place details call
|
||||
// This reduces Google Maps pricing.
|
||||
this.autocompleteSessionToken = null;
|
||||
|
||||
this.changeHighlight = this.changeHighlight.bind(this);
|
||||
this.selectItem = this.selectItem.bind(this);
|
||||
this.selectItemIfNoneSelected = this.selectItemIfNoneSelected.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.handleOnBlur = this.handleOnBlur.bind(this);
|
||||
this.handlePredictionsSelectStart = this.handlePredictionsSelectStart.bind(this);
|
||||
this.handlePredictionsSelectMove = this.handlePredictionsSelectMove.bind(this);
|
||||
this.handlePredictionsSelectEnd = this.handlePredictionsSelectEnd.bind(this);
|
||||
this.finalizeSelection = this.finalizeSelection.bind(this);
|
||||
|
||||
// Debounce the method to avoid calling the API too many times
|
||||
// when the user is typing fast.
|
||||
this.predict = debounce(this.predict.bind(this), DEBOUNCE_WAIT_TIME, { leading: true });
|
||||
}
|
||||
|
||||
// Interpret input key event
|
||||
onKeyDown(e) {
|
||||
if (e.keyCode === KEY_CODE_ARROW_UP) {
|
||||
// Prevent changing cursor position in input
|
||||
e.preventDefault();
|
||||
this.changeHighlight(DIRECTION_UP);
|
||||
} else if (e.keyCode === KEY_CODE_ARROW_DOWN) {
|
||||
// Prevent changing cursor position in input
|
||||
e.preventDefault();
|
||||
this.changeHighlight(DIRECTION_DOWN);
|
||||
} else if (e.keyCode === KEY_CODE_ENTER) {
|
||||
const { search, selectedPlace } = currentValue(this.props);
|
||||
|
||||
if (search && !selectedPlace) {
|
||||
// Prevent form submit, try to select value instead.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.selectItemIfNoneSelected();
|
||||
}
|
||||
} else if (e.keyCode === KEY_CODE_TAB) {
|
||||
this.selectItemIfNoneSelected();
|
||||
} else if (e.keyCode === KEY_CODE_ESC && this.input) {
|
||||
this.input.blur();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input text change, fetch predictions if the value isn't empty
|
||||
onChange(e) {
|
||||
const onChange = this.props.input.onChange;
|
||||
const { predictions } = currentValue(this.props);
|
||||
const newValue = e.target.value;
|
||||
|
||||
// Clear the current values since the input content is changed
|
||||
onChange({
|
||||
search: newValue,
|
||||
predictions: newValue ? predictions : [],
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
});
|
||||
|
||||
// Clear highlighted prediction since the input value changed and
|
||||
// results will change as well
|
||||
this.setState({ highlightedIndex: -1 });
|
||||
|
||||
if (!newValue) {
|
||||
// No need to fetch predictions on empty input
|
||||
return;
|
||||
}
|
||||
|
||||
this.predict(newValue);
|
||||
}
|
||||
|
||||
// Change the currently highlighted item by calculating the new
|
||||
// index from the current state and the given direction number
|
||||
// (DIRECTION_UP or DIRECTION_DOWN)
|
||||
changeHighlight(direction) {
|
||||
this.setState((prevState, props) => {
|
||||
const { predictions } = currentValue(props);
|
||||
const currentIndex = prevState.highlightedIndex;
|
||||
let index = currentIndex;
|
||||
|
||||
if (direction === DIRECTION_UP) {
|
||||
// Keep the first position if already highlighted
|
||||
index = currentIndex === 0 ? 0 : currentIndex - 1;
|
||||
} else if (direction === DIRECTION_DOWN) {
|
||||
index = currentIndex + 1;
|
||||
}
|
||||
|
||||
// Check that the index is within the bounds
|
||||
if (index < 0) {
|
||||
index = -1;
|
||||
} else if (index >= predictions.length) {
|
||||
index = predictions.length - 1;
|
||||
}
|
||||
|
||||
return { highlightedIndex: index };
|
||||
});
|
||||
}
|
||||
|
||||
// Select the prediction in the given item. This will fetch the
|
||||
// place details and set it as the selected place.
|
||||
selectItem(index) {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const { predictions } = currentValue(this.props);
|
||||
if (index >= predictions.length) {
|
||||
return;
|
||||
}
|
||||
const prediction = predictions[index];
|
||||
const placeId = prediction.place_id;
|
||||
|
||||
this.props.input.onChange({
|
||||
...this.props.input,
|
||||
selectedPlaceId: placeId,
|
||||
selectedPlace: null,
|
||||
});
|
||||
|
||||
this.autocompleteSessionToken =
|
||||
this.autocompleteSessionToken || new window.google.maps.places.AutocompleteSessionToken();
|
||||
const sessionToken = this.autocompleteSessionToken;
|
||||
|
||||
getPlaceDetails(placeId, sessionToken)
|
||||
.then(place => {
|
||||
this.props.input.onChange({
|
||||
search: prediction.description,
|
||||
predictions: [],
|
||||
selectedPlaceId: placeId,
|
||||
selectedPlace: place,
|
||||
});
|
||||
this.autocompleteSessionToken = null;
|
||||
})
|
||||
.catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
this.props.input.onChange({
|
||||
...this.props.input.value,
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
selectItemIfNoneSelected() {
|
||||
const { search, selectedPlaceId } = currentValue(this.props);
|
||||
if (search && !selectedPlaceId) {
|
||||
const index = this.state.highlightedIndex !== -1 ? this.state.highlightedIndex : 0;
|
||||
this.selectItem(index);
|
||||
}
|
||||
}
|
||||
predict(search) {
|
||||
const mapsLibLoaded = window.google && window.google.maps;
|
||||
if (!mapsLibLoaded) {
|
||||
throw new Error('Google Maps API must be loaded for LocationAutocompleteInput');
|
||||
}
|
||||
const onChange = this.props.input.onChange;
|
||||
|
||||
this.autocompleteSessionToken =
|
||||
this.autocompleteSessionToken || new window.google.maps.places.AutocompleteSessionToken();
|
||||
const sessionToken = this.autocompleteSessionToken;
|
||||
|
||||
getPlacePredictions(search, sessionToken)
|
||||
.then(results => {
|
||||
const { search: currentSearch } = currentValue(this.props);
|
||||
|
||||
// If the earlier predictions arrive when the user has already
|
||||
// changed the search term, ignore and wait until the latest
|
||||
// predictions arrive. Without this logic, results for earlier
|
||||
// requests would override whatever the user had typed since.
|
||||
//
|
||||
// This is essentially the same as switchLatest in RxJS or
|
||||
// takeLatest in Redux Saga, without canceling the earlier
|
||||
// requests.
|
||||
if (results.search === currentSearch) {
|
||||
onChange({
|
||||
search: results.search,
|
||||
predictions: results.predictions,
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
const value = currentValue(this.props);
|
||||
onChange({
|
||||
...value,
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
finalizeSelection() {
|
||||
this.setState({ inputHasFocus: false, highlightedIndex: -1 });
|
||||
this.props.input.onBlur(currentValue(this.props));
|
||||
}
|
||||
|
||||
handleOnBlur() {
|
||||
if (this.props.closeOnBlur && !this.state.selectionInProgress) {
|
||||
this.finalizeSelection();
|
||||
}
|
||||
}
|
||||
|
||||
handlePredictionsSelectStart(touchCoordinates) {
|
||||
this.setState({
|
||||
selectionInProgress: true,
|
||||
touchStartedFrom: touchCoordinates,
|
||||
isSwipe: false,
|
||||
});
|
||||
}
|
||||
|
||||
handlePredictionsSelectMove(touchCoordinates) {
|
||||
this.setState(prevState => {
|
||||
const touchStartedFrom = prevState.touchStartedFrom;
|
||||
const isTouchAction = !!touchStartedFrom;
|
||||
const isSwipe = isTouchAction
|
||||
? Math.abs(touchStartedFrom.y - touchCoordinates.y) > TOUCH_TAP_RADIUS
|
||||
: false;
|
||||
|
||||
return { selectionInProgress: false, isSwipe };
|
||||
});
|
||||
}
|
||||
|
||||
handlePredictionsSelectEnd(index) {
|
||||
this.setState(prevState => {
|
||||
if (!prevState.isSwipe) {
|
||||
this.selectItem(index);
|
||||
this.finalizeSelection();
|
||||
}
|
||||
return { selectionInProgress: false, touchStartedFrom: null, isSwipe: false };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
autoFocus,
|
||||
rootClassName,
|
||||
className,
|
||||
iconClassName,
|
||||
inputClassName,
|
||||
predictionsClassName,
|
||||
validClassName,
|
||||
placeholder,
|
||||
input,
|
||||
meta,
|
||||
inputRef,
|
||||
} = this.props;
|
||||
const { name, onFocus } = input;
|
||||
const { search, predictions } = currentValue(this.props);
|
||||
const { touched, valid } = meta || {};
|
||||
const isValid = valid && touched;
|
||||
|
||||
const handleOnFocus = e => {
|
||||
this.setState({ inputHasFocus: true });
|
||||
onFocus(e);
|
||||
};
|
||||
|
||||
const rootClass = classNames(rootClassName || css.root, className);
|
||||
const iconClass = classNames(iconClassName || css.icon);
|
||||
const inputClass = classNames(inputClassName || css.input, { [validClassName]: isValid });
|
||||
const predictionsClass = classNames(predictionsClassName);
|
||||
|
||||
// Only render predictions when the input has focus. For
|
||||
// development and easier workflow with the browser devtools, you
|
||||
// might want to hardcode this to `true`. Otherwise the dropdown
|
||||
// list will disappear.
|
||||
const renderPredictions = this.state.inputHasFocus;
|
||||
|
||||
return (
|
||||
<div className={rootClass}>
|
||||
<div className={iconClass}>
|
||||
<Icon />
|
||||
</div>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={search}
|
||||
onFocus={handleOnFocus}
|
||||
onBlur={this.handleOnBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
ref={node => {
|
||||
this.input = node;
|
||||
if (inputRef) {
|
||||
inputRef(node);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{renderPredictions ? (
|
||||
<LocationPredictionsList
|
||||
rootClassName={predictionsClass}
|
||||
predictions={predictions}
|
||||
highlightedIndex={this.state.highlightedIndex}
|
||||
onSelectStart={this.handlePredictionsSelectStart}
|
||||
onSelectMove={this.handlePredictionsSelectMove}
|
||||
onSelectEnd={this.handlePredictionsSelectEnd}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LocationAutocompleteInput.defaultProps = {
|
||||
autoFocus: false,
|
||||
closeOnBlur: true,
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
iconClassName: null,
|
||||
inputClassName: null,
|
||||
predictionsClassName: null,
|
||||
validClassName: null,
|
||||
placeholder: '',
|
||||
meta: null,
|
||||
inputRef: null,
|
||||
};
|
||||
|
||||
LocationAutocompleteInput.propTypes = {
|
||||
autoFocus: bool,
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
closeOnBlur: bool,
|
||||
iconClassName: string,
|
||||
inputClassName: string,
|
||||
predictionsClassName: string,
|
||||
validClassName: string,
|
||||
placeholder: string,
|
||||
input: shape({
|
||||
name: string.isRequired,
|
||||
value: oneOfType([
|
||||
shape({
|
||||
search: string,
|
||||
predictions: any,
|
||||
selectedPlace: propTypes.place,
|
||||
}),
|
||||
string,
|
||||
]),
|
||||
onChange: func.isRequired,
|
||||
onFocus: func.isRequired,
|
||||
onBlur: func.isRequired,
|
||||
}).isRequired,
|
||||
meta: shape({
|
||||
valid: bool.isRequired,
|
||||
touched: bool.isRequired,
|
||||
}),
|
||||
inputRef: func,
|
||||
};
|
||||
|
||||
export default LocationAutocompleteInput;
|
||||
import LocationAutocompleteInputGoogleMaps from './LocationAutocompleteInputGoogleMaps.js';
|
||||
|
||||
class LocationAutocompleteInputComponent extends Component {
|
||||
render() {
|
||||
|
|
@ -525,7 +22,7 @@ class LocationAutocompleteInputComponent extends Component {
|
|||
return (
|
||||
<div className={rootClassName}>
|
||||
{labelInfo}
|
||||
<LocationAutocompleteInput {...locationAutocompleteProps} />
|
||||
<LocationAutocompleteInputGoogleMaps {...locationAutocompleteProps} />
|
||||
<ValidationError fieldMeta={meta} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -550,6 +47,8 @@ LocationAutocompleteInputComponent.propTypes = {
|
|||
meta: object.isRequired,
|
||||
};
|
||||
|
||||
export default LocationAutocompleteInputGoogleMaps;
|
||||
|
||||
export const LocationAutocompleteInputField = props => {
|
||||
return <Field component={LocationAutocompleteInputComponent} {...props} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,507 @@
|
|||
import React, { Component } from 'react';
|
||||
import { any, arrayOf, bool, func, number, shape, string, oneOfType } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { getPlacePredictions, getPlaceDetails } from '../../util/googleMaps';
|
||||
|
||||
import css from './LocationAutocompleteInputGoogleMaps.css';
|
||||
|
||||
const DEBOUNCE_WAIT_TIME = 200;
|
||||
const KEY_CODE_ARROW_UP = 38;
|
||||
const KEY_CODE_ARROW_DOWN = 40;
|
||||
const KEY_CODE_ENTER = 13;
|
||||
const KEY_CODE_TAB = 9;
|
||||
const KEY_CODE_ESC = 27;
|
||||
const DIRECTION_UP = 'up';
|
||||
const DIRECTION_DOWN = 'down';
|
||||
const TOUCH_TAP_RADIUS = 5; // Movement within 5px from touch start is considered a tap
|
||||
|
||||
const Icon = () => (
|
||||
<svg
|
||||
className={css.iconSvg}
|
||||
width="21"
|
||||
height="22"
|
||||
viewBox="0 0 21 22"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
className={css.iconSvgGroup}
|
||||
transform="matrix(-1 0 0 1 20 1)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M13 14l5.241 5.241" />
|
||||
<circle cx="7.5" cy="7.5" r="7.5" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Touch devices need to be able to distinguish touches for scrolling and touches to tap
|
||||
const getTouchCoordinates = nativeEvent => {
|
||||
const touch = nativeEvent && nativeEvent.changedTouches ? nativeEvent.changedTouches[0] : null;
|
||||
return touch ? { x: touch.screenX, y: touch.screenY } : null;
|
||||
};
|
||||
|
||||
// Renders the autocompletion prediction results in a list
|
||||
const LocationPredictionsList = props => {
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
predictions,
|
||||
highlightedIndex,
|
||||
onSelectStart,
|
||||
onSelectMove,
|
||||
onSelectEnd,
|
||||
} = props;
|
||||
if (predictions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
const item = (prediction, index) => {
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={isHighlighted ? css.highlighted : null}
|
||||
key={prediction.id}
|
||||
onTouchStart={e => onSelectStart(getTouchCoordinates(e.nativeEvent))}
|
||||
onMouseDown={() => onSelectStart()}
|
||||
onTouchMove={e => onSelectMove(getTouchCoordinates(e.nativeEvent))}
|
||||
onTouchEnd={() => onSelectEnd(index)}
|
||||
onMouseUp={() => onSelectEnd(index)}
|
||||
>
|
||||
{prediction.description}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
|
||||
const classes = classNames(rootClassName || css.predictionsRoot, className);
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<ul className={css.predictions}>{predictions.map(item)}</ul>
|
||||
<div className={css.poweredByGoogle} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LocationPredictionsList.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
highlightedIndex: null,
|
||||
};
|
||||
|
||||
LocationPredictionsList.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
predictions: arrayOf(
|
||||
shape({
|
||||
id: string.isRequired,
|
||||
description: string.isRequired,
|
||||
place_id: string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
highlightedIndex: number,
|
||||
onSelectStart: func.isRequired,
|
||||
onSelectMove: func.isRequired,
|
||||
onSelectEnd: func.isRequired,
|
||||
};
|
||||
|
||||
// Get the current value with defaults from the given
|
||||
// LocationAutocompleteInput props.
|
||||
const currentValue = props => {
|
||||
const value = props.input.value || {};
|
||||
const { search = '', predictions = [], selectedPlaceId = null, selectedPlace = null } = value;
|
||||
return { search, predictions, selectedPlaceId, selectedPlace };
|
||||
};
|
||||
|
||||
/*
|
||||
Location auto completion input component
|
||||
|
||||
This component can work as the `component` prop to Final Form's
|
||||
<Field /> component. It takes a custom input value shape, and
|
||||
controls the onChange callback that is called with the input value.
|
||||
|
||||
The component works by listening to the underlying input component
|
||||
and calling the Google Maps Places API for predictions. When the
|
||||
predictions arrive, those are passed to Final Form in the onChange
|
||||
callback.
|
||||
|
||||
See the LocationAutocompleteInput.example.js file for a usage
|
||||
example within a form.
|
||||
*/
|
||||
class LocationAutocompleteInputGoogleMaps extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inputHasFocus: false,
|
||||
selectionInProgress: false,
|
||||
touchStartedFrom: null,
|
||||
highlightedIndex: -1, // -1 means no highlight
|
||||
};
|
||||
|
||||
// Ref to the input element.
|
||||
this.input = null;
|
||||
|
||||
// Current sessionToken used to combine autocomplete calls with place details call
|
||||
// This reduces Google Maps pricing.
|
||||
this.autocompleteSessionToken = null;
|
||||
|
||||
this.changeHighlight = this.changeHighlight.bind(this);
|
||||
this.selectItem = this.selectItem.bind(this);
|
||||
this.selectItemIfNoneSelected = this.selectItemIfNoneSelected.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.handleOnBlur = this.handleOnBlur.bind(this);
|
||||
this.handlePredictionsSelectStart = this.handlePredictionsSelectStart.bind(this);
|
||||
this.handlePredictionsSelectMove = this.handlePredictionsSelectMove.bind(this);
|
||||
this.handlePredictionsSelectEnd = this.handlePredictionsSelectEnd.bind(this);
|
||||
this.finalizeSelection = this.finalizeSelection.bind(this);
|
||||
|
||||
// Debounce the method to avoid calling the API too many times
|
||||
// when the user is typing fast.
|
||||
this.predict = debounce(this.predict.bind(this), DEBOUNCE_WAIT_TIME, { leading: true });
|
||||
}
|
||||
|
||||
// Interpret input key event
|
||||
onKeyDown(e) {
|
||||
if (e.keyCode === KEY_CODE_ARROW_UP) {
|
||||
// Prevent changing cursor position in input
|
||||
e.preventDefault();
|
||||
this.changeHighlight(DIRECTION_UP);
|
||||
} else if (e.keyCode === KEY_CODE_ARROW_DOWN) {
|
||||
// Prevent changing cursor position in input
|
||||
e.preventDefault();
|
||||
this.changeHighlight(DIRECTION_DOWN);
|
||||
} else if (e.keyCode === KEY_CODE_ENTER) {
|
||||
const { search, selectedPlace } = currentValue(this.props);
|
||||
|
||||
if (search && !selectedPlace) {
|
||||
// Prevent form submit, try to select value instead.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.selectItemIfNoneSelected();
|
||||
}
|
||||
} else if (e.keyCode === KEY_CODE_TAB) {
|
||||
this.selectItemIfNoneSelected();
|
||||
} else if (e.keyCode === KEY_CODE_ESC && this.input) {
|
||||
this.input.blur();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input text change, fetch predictions if the value isn't empty
|
||||
onChange(e) {
|
||||
const onChange = this.props.input.onChange;
|
||||
const { predictions } = currentValue(this.props);
|
||||
const newValue = e.target.value;
|
||||
|
||||
// Clear the current values since the input content is changed
|
||||
onChange({
|
||||
search: newValue,
|
||||
predictions: newValue ? predictions : [],
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
});
|
||||
|
||||
// Clear highlighted prediction since the input value changed and
|
||||
// results will change as well
|
||||
this.setState({ highlightedIndex: -1 });
|
||||
|
||||
if (!newValue) {
|
||||
// No need to fetch predictions on empty input
|
||||
return;
|
||||
}
|
||||
|
||||
this.predict(newValue);
|
||||
}
|
||||
|
||||
// Change the currently highlighted item by calculating the new
|
||||
// index from the current state and the given direction number
|
||||
// (DIRECTION_UP or DIRECTION_DOWN)
|
||||
changeHighlight(direction) {
|
||||
this.setState((prevState, props) => {
|
||||
const { predictions } = currentValue(props);
|
||||
const currentIndex = prevState.highlightedIndex;
|
||||
let index = currentIndex;
|
||||
|
||||
if (direction === DIRECTION_UP) {
|
||||
// Keep the first position if already highlighted
|
||||
index = currentIndex === 0 ? 0 : currentIndex - 1;
|
||||
} else if (direction === DIRECTION_DOWN) {
|
||||
index = currentIndex + 1;
|
||||
}
|
||||
|
||||
// Check that the index is within the bounds
|
||||
if (index < 0) {
|
||||
index = -1;
|
||||
} else if (index >= predictions.length) {
|
||||
index = predictions.length - 1;
|
||||
}
|
||||
|
||||
return { highlightedIndex: index };
|
||||
});
|
||||
}
|
||||
|
||||
// Select the prediction in the given item. This will fetch the
|
||||
// place details and set it as the selected place.
|
||||
selectItem(index) {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const { predictions } = currentValue(this.props);
|
||||
if (index >= predictions.length) {
|
||||
return;
|
||||
}
|
||||
const prediction = predictions[index];
|
||||
const placeId = prediction.place_id;
|
||||
|
||||
this.props.input.onChange({
|
||||
...this.props.input,
|
||||
selectedPlaceId: placeId,
|
||||
selectedPlace: null,
|
||||
});
|
||||
|
||||
this.autocompleteSessionToken =
|
||||
this.autocompleteSessionToken || new window.google.maps.places.AutocompleteSessionToken();
|
||||
const sessionToken = this.autocompleteSessionToken;
|
||||
|
||||
getPlaceDetails(placeId, sessionToken)
|
||||
.then(place => {
|
||||
this.props.input.onChange({
|
||||
search: prediction.description,
|
||||
predictions: [],
|
||||
selectedPlaceId: placeId,
|
||||
selectedPlace: place,
|
||||
});
|
||||
this.autocompleteSessionToken = null;
|
||||
})
|
||||
.catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
this.props.input.onChange({
|
||||
...this.props.input.value,
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
selectItemIfNoneSelected() {
|
||||
const { search, selectedPlaceId } = currentValue(this.props);
|
||||
if (search && !selectedPlaceId) {
|
||||
const index = this.state.highlightedIndex !== -1 ? this.state.highlightedIndex : 0;
|
||||
this.selectItem(index);
|
||||
}
|
||||
}
|
||||
predict(search) {
|
||||
const mapsLibLoaded = window.google && window.google.maps;
|
||||
if (!mapsLibLoaded) {
|
||||
throw new Error('Google Maps API must be loaded for LocationAutocompleteInput');
|
||||
}
|
||||
const onChange = this.props.input.onChange;
|
||||
|
||||
this.autocompleteSessionToken =
|
||||
this.autocompleteSessionToken || new window.google.maps.places.AutocompleteSessionToken();
|
||||
const sessionToken = this.autocompleteSessionToken;
|
||||
|
||||
getPlacePredictions(search, sessionToken)
|
||||
.then(results => {
|
||||
const { search: currentSearch } = currentValue(this.props);
|
||||
|
||||
// If the earlier predictions arrive when the user has already
|
||||
// changed the search term, ignore and wait until the latest
|
||||
// predictions arrive. Without this logic, results for earlier
|
||||
// requests would override whatever the user had typed since.
|
||||
//
|
||||
// This is essentially the same as switchLatest in RxJS or
|
||||
// takeLatest in Redux Saga, without canceling the earlier
|
||||
// requests.
|
||||
if (results.search === currentSearch) {
|
||||
onChange({
|
||||
search: results.search,
|
||||
predictions: results.predictions,
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
const value = currentValue(this.props);
|
||||
onChange({
|
||||
...value,
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
finalizeSelection() {
|
||||
this.setState({ inputHasFocus: false, highlightedIndex: -1 });
|
||||
this.props.input.onBlur(currentValue(this.props));
|
||||
}
|
||||
|
||||
handleOnBlur() {
|
||||
if (this.props.closeOnBlur && !this.state.selectionInProgress) {
|
||||
this.finalizeSelection();
|
||||
}
|
||||
}
|
||||
|
||||
handlePredictionsSelectStart(touchCoordinates) {
|
||||
this.setState({
|
||||
selectionInProgress: true,
|
||||
touchStartedFrom: touchCoordinates,
|
||||
isSwipe: false,
|
||||
});
|
||||
}
|
||||
|
||||
handlePredictionsSelectMove(touchCoordinates) {
|
||||
this.setState(prevState => {
|
||||
const touchStartedFrom = prevState.touchStartedFrom;
|
||||
const isTouchAction = !!touchStartedFrom;
|
||||
const isSwipe = isTouchAction
|
||||
? Math.abs(touchStartedFrom.y - touchCoordinates.y) > TOUCH_TAP_RADIUS
|
||||
: false;
|
||||
|
||||
return { selectionInProgress: false, isSwipe };
|
||||
});
|
||||
}
|
||||
|
||||
handlePredictionsSelectEnd(index) {
|
||||
this.setState(prevState => {
|
||||
if (!prevState.isSwipe) {
|
||||
this.selectItem(index);
|
||||
this.finalizeSelection();
|
||||
}
|
||||
return { selectionInProgress: false, touchStartedFrom: null, isSwipe: false };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
autoFocus,
|
||||
rootClassName,
|
||||
className,
|
||||
iconClassName,
|
||||
inputClassName,
|
||||
predictionsClassName,
|
||||
validClassName,
|
||||
placeholder,
|
||||
input,
|
||||
meta,
|
||||
inputRef,
|
||||
} = this.props;
|
||||
const { name, onFocus } = input;
|
||||
const { search, predictions } = currentValue(this.props);
|
||||
const { touched, valid } = meta || {};
|
||||
const isValid = valid && touched;
|
||||
|
||||
const handleOnFocus = e => {
|
||||
this.setState({ inputHasFocus: true });
|
||||
onFocus(e);
|
||||
};
|
||||
|
||||
const rootClass = classNames(rootClassName || css.root, className);
|
||||
const iconClass = classNames(iconClassName || css.icon);
|
||||
const inputClass = classNames(inputClassName || css.input, { [validClassName]: isValid });
|
||||
const predictionsClass = classNames(predictionsClassName);
|
||||
|
||||
// Only render predictions when the input has focus. For
|
||||
// development and easier workflow with the browser devtools, you
|
||||
// might want to hardcode this to `true`. Otherwise the dropdown
|
||||
// list will disappear.
|
||||
const renderPredictions = this.state.inputHasFocus;
|
||||
|
||||
return (
|
||||
<div className={rootClass}>
|
||||
<div className={iconClass}>
|
||||
<Icon />
|
||||
</div>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={search}
|
||||
onFocus={handleOnFocus}
|
||||
onBlur={this.handleOnBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
ref={node => {
|
||||
this.input = node;
|
||||
if (inputRef) {
|
||||
inputRef(node);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{renderPredictions ? (
|
||||
<LocationPredictionsList
|
||||
rootClassName={predictionsClass}
|
||||
predictions={predictions}
|
||||
highlightedIndex={this.state.highlightedIndex}
|
||||
onSelectStart={this.handlePredictionsSelectStart}
|
||||
onSelectMove={this.handlePredictionsSelectMove}
|
||||
onSelectEnd={this.handlePredictionsSelectEnd}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LocationAutocompleteInputGoogleMaps.defaultProps = {
|
||||
autoFocus: false,
|
||||
closeOnBlur: true,
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
iconClassName: null,
|
||||
inputClassName: null,
|
||||
predictionsClassName: null,
|
||||
validClassName: null,
|
||||
placeholder: '',
|
||||
meta: null,
|
||||
inputRef: null,
|
||||
};
|
||||
|
||||
LocationAutocompleteInputGoogleMaps.propTypes = {
|
||||
autoFocus: bool,
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
closeOnBlur: bool,
|
||||
iconClassName: string,
|
||||
inputClassName: string,
|
||||
predictionsClassName: string,
|
||||
validClassName: string,
|
||||
placeholder: string,
|
||||
input: shape({
|
||||
name: string.isRequired,
|
||||
value: oneOfType([
|
||||
shape({
|
||||
search: string,
|
||||
predictions: any,
|
||||
selectedPlace: propTypes.place,
|
||||
}),
|
||||
string,
|
||||
]),
|
||||
onChange: func.isRequired,
|
||||
onFocus: func.isRequired,
|
||||
onBlur: func.isRequired,
|
||||
}).isRequired,
|
||||
meta: shape({
|
||||
valid: bool.isRequired,
|
||||
touched: bool.isRequired,
|
||||
}),
|
||||
inputRef: func,
|
||||
};
|
||||
|
||||
|
||||
export default LocationAutocompleteInputGoogleMaps;
|
||||
Loading…
Add table
Reference in a new issue