mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-25 22:37:18 +10:00
LocationAutocompleteInput
This commit is contained in:
parent
f137137ce9
commit
e15614877e
5 changed files with 393 additions and 1 deletions
|
|
@ -5,9 +5,10 @@
|
|||
<!--!title-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<!--!preloadedStateScript-->
|
||||
<!--!preloadedStateScript-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><!--!body--></div>
|
||||
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAIhL8ykNE8WcPihRdpkL33TXng-0SYDr0&libraries=places"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.predictions {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-top: none;
|
||||
|
||||
& li {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.highlighted {
|
||||
background-color: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import React, { Component } from 'react';
|
||||
import { Field, reduxForm, propTypes as formPropTypes } from 'redux-form';
|
||||
import LocationAutocompleteInput from './LocationAutocompleteInput';
|
||||
|
||||
const FormComponent = props => {
|
||||
const { handleSubmit, pristine, submitting } = props;
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="location">Select location:</label>
|
||||
<Field name="location" format={null} component={LocationAutocompleteInput} />
|
||||
<button type="submit" disabled={pristine || submitting}>Submit</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
FormComponent.propTypes = formPropTypes;
|
||||
|
||||
const Form = reduxForm({
|
||||
form: 'Styleguide.LocationAutocompleteInput.Form',
|
||||
})(FormComponent);
|
||||
|
||||
class FormContainer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { location: {} };
|
||||
}
|
||||
render() {
|
||||
const onSubmit = values => {
|
||||
this.setState({ location: values.location });
|
||||
};
|
||||
const place = this.state.location.selectedPlace;
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
Search for a place name or address, select on with mouse or keyboard, and submit the form.
|
||||
</p>
|
||||
<Form onSubmit={onSubmit} />
|
||||
{place
|
||||
? <p>
|
||||
Submitted place: {place.address} (
|
||||
{place.origin.lat},
|
||||
{place.origin.lng},
|
||||
{place.bounds ? ' with' : ' without'} bounds
|
||||
)
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Empty = {
|
||||
component: FormContainer,
|
||||
};
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { getPlacePredictions, getPlaceDetails } from '../../util/googleMaps';
|
||||
|
||||
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_ESC = 27;
|
||||
const KEY_CODE_ENTER = 13;
|
||||
const DIRECTION_UP = 'up';
|
||||
const DIRECTION_DOWN = 'down';
|
||||
|
||||
// Renders the autocompletion prediction results in a list
|
||||
const LocationPredictionsList = props => {
|
||||
const { predictions, highlightedIndex, onSelectItem } = 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}
|
||||
onClick={() => onSelectItem(index)}
|
||||
>
|
||||
{prediction.description}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
|
||||
return (
|
||||
<ul className={css.predictions}>
|
||||
{predictions.map(item)}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const { shape, string, arrayOf, func, any, number } = PropTypes;
|
||||
|
||||
LocationPredictionsList.defaultProps = { highlightedIndex: null };
|
||||
|
||||
LocationPredictionsList.propTypes = {
|
||||
predictions: arrayOf(
|
||||
shape({
|
||||
id: string.isRequired,
|
||||
description: string.isRequired,
|
||||
place_id: string.isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
highlightedIndex: number,
|
||||
onSelectItem: func.isRequired,
|
||||
};
|
||||
|
||||
// Get the current value with defaults from the given
|
||||
// LocationAutocompleteInput props.
|
||||
const currentValue = props => {
|
||||
const value = props.input.value || {};
|
||||
return { search: '', predictions: [], selectedPlace: null, ...value };
|
||||
};
|
||||
|
||||
/*
|
||||
Location auto completion input component
|
||||
|
||||
This component can work as the `component` prop to Redux Form's
|
||||
<Field /> component. it takes a custom input value shape, and
|
||||
controls the onChanged callback that is called with the value to
|
||||
syncronise to the form's Redux store.
|
||||
|
||||
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 Redux 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,
|
||||
predictionsHaveHover: false,
|
||||
highlightedIndex: -1, // -1 means no highlight
|
||||
};
|
||||
|
||||
this.changeHighlight = this.changeHighlight.bind(this);
|
||||
this.selectItem = this.selectItem.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onChange = this.onChange.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 });
|
||||
}
|
||||
componentDidMount() {
|
||||
const mapsLibLoaded = window.google && window.google.maps;
|
||||
if (!mapsLibLoaded) {
|
||||
throw new Error('Google Maps API must be loaded for LocationAutocompleteInput');
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.selectItem(this.state.highlightedIndex);
|
||||
} else if (e.keyCode === KEY_CODE_ESC) {
|
||||
// Somehow by default pressing the ESC key clears the
|
||||
// focused input value. Preventing the default seems to
|
||||
// fix this.
|
||||
e.preventDefault();
|
||||
|
||||
// Trigger input blur to hide predictions dropdown
|
||||
this.input.blur();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input text change, fetch predictions if the value isn't empty
|
||||
onChange(e) {
|
||||
// We want to fully control how changes propagate up the tree, and
|
||||
// therefore prevent default action and stop propagating the event
|
||||
// through the (virtual) DOM.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const onChange = this.props.input.onChange;
|
||||
const { predictions } = currentValue(this.props);
|
||||
const newValue = this.input.value;
|
||||
|
||||
// Clear the current values since the input content is changed
|
||||
onChange({
|
||||
search: newValue,
|
||||
predictions: newValue ? predictions : [],
|
||||
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];
|
||||
|
||||
getPlaceDetails(prediction.place_id)
|
||||
.then(place => {
|
||||
this.props.input.onChange({
|
||||
search: prediction.description,
|
||||
predictions: [],
|
||||
selectedPlace: place,
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
this.props.input.onChange({
|
||||
...this.props.input.value,
|
||||
selectedPlace: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
predict(search) {
|
||||
const onChange = this.props.input.onChange;
|
||||
getPlacePredictions(search)
|
||||
.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,
|
||||
selectedPlace: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
const value = currentValue(this.props);
|
||||
onChange({
|
||||
...value,
|
||||
selectedPlace: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
render() {
|
||||
const { search, predictions } = currentValue(this.props);
|
||||
|
||||
// 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 || this.state.predictionsHaveHover;
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<input
|
||||
className={css.input}
|
||||
type="search"
|
||||
value={search}
|
||||
onFocus={() => this.setState({ inputHasFocus: true })}
|
||||
onBlur={() => this.setState({ inputHasFocus: false, highlightedIndex: -1 })}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
ref={i => {
|
||||
this.input = i;
|
||||
}}
|
||||
/>
|
||||
{renderPredictions
|
||||
? <div
|
||||
onMouseEnter={() => this.setState({ predictionsHaveHover: true })}
|
||||
onMouseLeave={() => this.setState({ predictionsHaveHover: false })}
|
||||
>
|
||||
<LocationPredictionsList
|
||||
predictions={predictions}
|
||||
highlightedIndex={this.state.highlightedIndex}
|
||||
onSelectItem={this.selectItem}
|
||||
/>
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LocationAutocompleteInput.propTypes = {
|
||||
input: shape({
|
||||
value: shape({
|
||||
search: string,
|
||||
predictions: any,
|
||||
selectedPlace: propTypes.place,
|
||||
}),
|
||||
onChange: func.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default LocationAutocompleteInput;
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
import * as BookingInfo from './components/BookingInfo/BookingInfo.example';
|
||||
import * as ListingCard from './components/ListingCard/ListingCard.example';
|
||||
import * as NamedLink from './components/NamedLink/NamedLink.example';
|
||||
import * as LocationAutocompleteInput
|
||||
from './components/LocationAutocompleteInput/LocationAutocompleteInput.example';
|
||||
|
||||
// containers
|
||||
import * as ChangeAccountPasswordForm
|
||||
|
|
@ -25,4 +27,5 @@ export {
|
|||
NamedLink,
|
||||
PasswordForgottenForm,
|
||||
SignUpForm,
|
||||
LocationAutocompleteInput,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue