├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── demo.gif ├── demos ├── 1_quickstart │ ├── Demo.css │ ├── Demo.js │ ├── index.html │ └── index.js └── 2_barebones │ ├── Demo.css │ ├── Demo.js │ ├── index.html │ └── index.js ├── package.json ├── postcss.config.js ├── src ├── DefaultGeoInput.css ├── DefaultGeoInput.js ├── GeoAddressInput.css ├── GeoAddressInput.js ├── PredictiveInput.css ├── PredictiveInput.js ├── createGeoInput.js ├── index.js └── utils.js ├── webpack.config.js ├── webpack.demo.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-1"], 3 | "plugins": ["transform-object-rest-spread", "transform-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "node": true, 5 | "mocha": true, 6 | "browser": true 7 | }, 8 | "globals": { 9 | "expect": true, 10 | "sinon": true 11 | }, 12 | "parser": "babel-eslint", 13 | "rules": { 14 | "react/jsx-filename-extension": 0, 15 | "react/forbid-prop-types": 0, 16 | "react/no-multi-comp": 0, 17 | "import/no-extraneous-dependencies": 0, 18 | "import/no-unresolved": 0, 19 | "import/extensions": 0, 20 | "react/require-default-props": 0, 21 | "jsx-a11y/no-static-element-interactions": 0, 22 | "jsx-a11y/href-no-hash": "off" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.log 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demos 2 | docs 3 | .babelrc 4 | .eslint* 5 | .editorconfig 6 | .npmignore 7 | webpack.* 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/node_modules": true, 4 | "lib": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Wolt Enterprises 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-geoinput 2 | 3 | :warning: This library has been deprecated and is being no longer maintained :warning: 4 | 5 | [![npm version](https://badge.fury.io/js/react-geoinput.svg)](https://badge.fury.io/js/react-geoinput) 6 | [![Download Count](http://img.shields.io/npm/dm/react-geoinput.svg?style=flat-square)](https://npmjs.org/package/react-geoinput) 7 | 8 | > Redux-form compatible geolocation suggestions and coordinates with Google Maps API. 9 | 10 | react-geoinput example 11 | 12 | ## Features 13 | 14 | * Suggestion of locations with Google Maps API 15 | * Address geocoding with Google Maps API 16 | * Customizable debounced input 17 | * Customizable suggestion serialization and rendering 18 | * Customizable geo destination serialization and rendering 19 | * Standard `input` interface (compatible with `redux-form`) 20 | 21 | ## Install 22 | 23 | 1. add `react-geoinput` as dependency 24 | ``` 25 | npm install --save react-geoinput 26 | ``` 27 | 28 | 2. include Google Maps API 29 | 30 | Make `window.google.maps` available e.g. with: 31 | 32 | ```html 33 | 34 | ``` 35 | 36 | link: [Google Maps JavaScript API 37 | / get API key](https://developers.google.com/maps/documentation/javascript/get-api-key) 38 | 39 | ## Try demos locally 40 | 41 | ``` 42 | $ git clone https://github.com/woltapp/react-geoinput.git 43 | $ cd react-geoinput 44 | $ yarn 45 | $ yarn start 46 | ``` 47 | 48 | * `1_quickstart` demonstrates the use of `DefaultGeoInput` 49 | * `2_barebones` uses only `createGeoInput` and demonstrates how to use the API to create your own input 50 | 51 | ## What problem does the library solve? 52 | 53 | React-geoinput makes it a breeze to combine both __geolocation suggestion__ 54 | and __geocoding an address__. Generally other libraries do only either at once. A good use case for this library is to be able to turn an address into coordinates and verify that the interpreted address was correct in textual format. Moreover, this library allows complete customization of the UI, and only provides components to get you quickly started! 55 | 56 | ## Examples 57 | 58 | ### Quick start 59 | 60 | ```jsx 61 | import React, { Component } from 'react'; 62 | import { createGeoInput, DefaultGeoInput } from 'react-geoinput'; 63 | 64 | const SimpleInput = createGeoInput(DefaultGeoInput); 65 | 66 | class Example extends Component { 67 | state = { 68 | address: '', 69 | geoDestination: '', 70 | } 71 | 72 | onAddressChange = value => this.setState({ address: value }) 73 | onGeoDestinationChange = value => this.setState({ geoDestination: value }) 74 | 75 | render() { 76 | return ( 77 |
78 | 88 |
89 | ); 90 | } 91 | } 92 | ``` 93 | 94 | ### Usage with Redux-form 95 | 96 | ```jsx 97 | import React from 'react'; 98 | import { Fields } from 'redux-form'; 99 | import { createGeoInput, DefaultGeoInput } from 'react-geoinput'; 100 | 101 | const GeoInput = createGeoInput(DefaultGeoInput); 102 | 103 | const GeoField = fields => ( 104 | 108 | ); 109 | ``` 110 | 111 | Use with `redux-form`'s `Fields` component: 112 | 113 | ```jsx 114 | 115 | ``` 116 | 117 |
118 | 119 | ## API Documentation 120 | 121 | ### Overview 122 | 123 | React-geoinput exposes one higher order component (`createGeoInput`) and three regular 124 | stateless React components (`DefaultGeoInput`, `GeoAddressInput`, `PredictiveInput`). 125 | 126 | `createGeoInput` contains the main logic to handle fetching location 127 | suggestions from the Google Maps API and to geocode the typed 128 | address to a location object, which includes e.g. coordinates and parsed 129 | location fields. In fact, `createGeoInput` provides __two__ inputs simultaneously: 130 | typed address and geocoded location. Generally you'll want to store the information 131 | separately, since address is the arbitrary string typed by user and location 132 | is the accurate exact geolocation. 133 | 134 | `DefaultGeoInput` exists to get you quickly started with the library. It contains 135 | opinionated styles and structure, which is a good starting point. If it works 136 | for you, you can customize it via the props, otherwise you can use 137 | it simply as a starting point to create your own completely custom input component. 138 | `DefaultGeoInput` uses `GeoAddressInput` underneath to provide the bare-bones 139 | input with predictions (=suggestions). 140 | 141 | `GeoAddressInput` is provided as a convenience component, which simply maps 142 | the predictions (suggestions) from `createGeoInput()` to `PredictiveInput`. 143 | 144 | `PredictiveInput` is provided as a utility component to provide a simple 145 | input field with predictions -- it is not coupled to geocoding or locations anyhow. 146 | It should be applicable for most cases and supports styling via props. 147 | `PredictiveInput` uses `DebounceInput` from `react-debounce-input` to reduce 148 | the amount of requests made to the Google Maps API. 149 | 150 | ### `createGeoInput(input: Component, )` 151 | 152 | `createGeoInput` is a higher order component that takes two arguments, first of which is your custom input (or `DefaultGeoInput`), 153 | and the second one is options object. It can be wrapped with a custom input component, such as with the provided `DefaultGeoInput`. The beef of this library's logic is in this HoC; thus you are encouraged to make a custom implementation of the input. 154 | 155 | ##### The following options can be set: 156 | 157 | * __`serializePrediction`__ _(Function)_: A function that takes `prediction` object 158 | from the Google Maps API as an argument and turns it into a string that is suggested. 159 | The structure of the `prediction` object is not included in this documentation. 160 | 161 | * __`serializeGeoDestination`__ _(Function)_: A function that takes `geoDestination` object 162 | from the Google Maps API as an argument and turns it to another object. By default it maps 163 | the `geoDestination` keys as following: `route->street`, `street_number->streetNumber`, 164 | `subpremise->subpremise`, `locality->city`, `country->country`, `postal_code->postalCode`, 165 | `{ geometry }->{ lat, lng},viewport`. The structure of the `geoDestination` object is not 166 | included in this documentation. 167 | 168 | > _Note: you won't need to change these options unless you know you are missing 169 | an important value from the Google Maps API._ 170 | 171 | 172 | ### `DefaultGeoInput` 173 | 174 | `DefaultGeoInput` displays an input for typing the address. Predictions (=suggestions) will 175 | be shown for the address with `PredictiveInput`. On predicted address selection the `geoDestionation` 176 | will be also rendered. 177 | 178 | > _Note: a good way to get started with your completely custom input is to copy the implementation of 179 | `DefaultGeoInput` and modify it._ 180 | 181 | #### Props 182 | 183 | * `activeIndex (number)`: control the selected index of location suggestion 184 | * `addressInput (object.isRequired)`: input controls, such as `onChange`, `value` 185 | * `className (string)` 186 | * `geoDestinationInput (object.isRequired)`: input controls, such as `onChange`, `value` 187 | * `geoDestinationClassName (string)` 188 | * `geoDestinationTableClassName (string)` 189 | * `loadingElement (node)`: element to display while loading geo destination 190 | * `loadingGeoDestination (bool)`: control when to show `loadingElement` 191 | * `onPredictionClick (func.isRequired)`: handle suggestion click, takes prediction `index` 192 | * `predictions (array.isRequired)`: array of predictions from Google Maps API 193 | * `style (object)` 194 | 195 | ### `GeoAddressInput` 196 | 197 | #### Props 198 | 199 | * `activeIndex (number)`: control the selected index of location suggestion 200 | * `className (string)` 201 | * `onPredictionClick (func.isRequired)`: handles prediction click, takes prediction `index` 202 | * `onChange (func.isRequired)`: handle for address input change 203 | * `predictions (array.isRequired)`: array of predictions from Google Maps API 204 | * `style (object)` 205 | 206 | 207 | ### `PredictiveInput` 208 | 209 | #### Props 210 | 211 | * `className (string)` 212 | * `containerClassName (string)`: 213 | * `containerStyle (object)`: 214 | * `debounceTimeout (number)`: time for debounce in ms 215 | * `activePredictionId (string|number)`: control active prediction 216 | * `predictions (arrayOf(predictionPropType))`: array of predictions (see below) 217 | * `predictionsClassName (string)` 218 | * `predictionItemClassName (string)` 219 | 220 | ```js 221 | predictionPropType = PropTypes.shape({ 222 | id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 223 | label: PropTypes.node, 224 | onClick: PropTypes.func, 225 | }) 226 | ``` 227 | 228 | ## License 229 | 230 | MIT 231 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woltapp/react-geoinput/728a72ec8207a64e144e0f3d9f9d9774876f36a3/demo.gif -------------------------------------------------------------------------------- /demos/1_quickstart/Demo.css: -------------------------------------------------------------------------------- 1 | .addressInput { 2 | font-size: 13px; 3 | border-radius: 3px; 4 | border: 1px solid #ccc; 5 | outline: none; 6 | padding: 5px 8px; 7 | width: 240px; 8 | } 9 | 10 | .geoDestination { 11 | font-size: 15px; 12 | border-radius: 3px; 13 | } 14 | 15 | .loadingContainer { 16 | margin-top: 10px; 17 | font-size: 15px; 18 | } 19 | -------------------------------------------------------------------------------- /demos/1_quickstart/Demo.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { createGeoInput, DefaultGeoInput } from 'react-geoinput'; 3 | 4 | import styles from './Demo.css'; 5 | 6 | const DemoInput = createGeoInput(DefaultGeoInput); 7 | 8 | class DemoView extends Component { 9 | state = { 10 | address: '', 11 | geoDestination: '', 12 | } 13 | 14 | onAddressChange = value => this.setState({ address: value }) 15 | 16 | onGeoDestinationChange = value => this.setState({ geoDestination: value }) 17 | 18 | render() { 19 | return ( 20 |
21 | Loading ...
} 23 | geoDestinationClassName={styles.geoDestination} 24 | addressInput={{ 25 | className: styles.addressInput, 26 | onChange: this.onAddressChange, 27 | value: this.state.address, 28 | }} 29 | geoDestinationInput={{ 30 | onChange: this.onGeoDestinationChange, 31 | value: this.state.geoDestination, 32 | }} 33 | /> 34 | 35 | ); 36 | } 37 | } 38 | 39 | export default DemoView; 40 | -------------------------------------------------------------------------------- /demos/1_quickstart/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-geoinput/demos/quickstart 6 | 7 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /demos/1_quickstart/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Demo from './Demo'; 4 | 5 | ReactDOM.render(, document.querySelector('#app')); 6 | -------------------------------------------------------------------------------- /demos/2_barebones/Demo.css: -------------------------------------------------------------------------------- 1 | .addressInput { 2 | font-size: 13px; 3 | border-radius: 3px; 4 | border: 1px solid #ccc; 5 | outline: none; 6 | padding: 5px 8px; 7 | width: 240px; 8 | } 9 | 10 | .geoDestination { 11 | font-size: 15px; 12 | border-radius: 3px; 13 | } 14 | 15 | .loadingContainer { 16 | margin-top: 10px; 17 | font-size: 15px; 18 | } 19 | -------------------------------------------------------------------------------- /demos/2_barebones/Demo.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-array-index-key */ 2 | import React, { Component } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { createGeoInput } from 'react-geoinput'; 5 | 6 | import styles from './Demo.css'; 7 | 8 | const BarebonesGeoInput = ({ 9 | addressInput, 10 | loadingGeoDestination, 11 | geoDestinationInput, 12 | onPredictionClick, 13 | predictions, 14 | }) => ( 15 |
16 | 17 | 18 | {loadingGeoDestination &&
Loading geo destination ...
} 19 | 20 |
21 | 22 |
23 |

24 | predictions: 25 |

26 | 27 |
28 | {!!predictions && !!predictions.length ? predictions.map((prediction, index) => ( 29 |
33 | {JSON.stringify(prediction)} 34 | 35 |
36 | 42 |
43 |
44 | )) : '-'} 45 |
46 |
47 | 48 |
49 | 50 |
51 |

52 | geoDestination value: 53 |

54 | 55 |
56 |
 57 |           {geoDestinationInput.value
 58 |             ? JSON.stringify(geoDestinationInput.value, null, 2)
 59 |             : '-'}
 60 |         
61 |
62 |
63 | 64 |
65 | ); 66 | 67 | BarebonesGeoInput.propTypes = { 68 | addressInput: PropTypes.shape({ 69 | onChange: PropTypes.func.isRequired, 70 | value: PropTypes.string, 71 | }).isRequired, 72 | loadingGeoDestination: PropTypes.bool.isRequired, 73 | geoDestinationInput: PropTypes.shape({ 74 | value: PropTypes.object, 75 | }).isRequired, 76 | onPredictionClick: PropTypes.func.isRequired, 77 | predictions: PropTypes.arrayOf(PropTypes.shape({ 78 | id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 79 | label: PropTypes.node, 80 | onClick: PropTypes.func, 81 | })).isRequired, 82 | }; 83 | 84 | const DemoInput = createGeoInput(BarebonesGeoInput); 85 | 86 | class DemoView extends Component { 87 | state = { 88 | address: '', 89 | geoDestination: undefined, 90 | } 91 | 92 | onAddressChange = value => this.setState({ address: value }) 93 | 94 | onGeoDestinationChange = value => this.setState({ geoDestination: value }) 95 | 96 | render() { 97 | return ( 98 |
99 | 110 |
111 | ); 112 | } 113 | } 114 | 115 | export default DemoView; 116 | -------------------------------------------------------------------------------- /demos/2_barebones/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-geoinput/demos/barebones 6 | 7 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /demos/2_barebones/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Demo from './Demo'; 4 | 5 | ReactDOM.render(, document.querySelector('#app')); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-geoinput", 3 | "version": "1.0.3", 4 | "description": "Geolocation suggestions and coordinates with Google Maps API for React", 5 | "main": "./lib/react-geoinput.js", 6 | "scripts": { 7 | "start": "npm run demo", 8 | "build": "webpack --config webpack.config.js --progress --colors", 9 | "test": "cross-env NODE_ENV=test karma start", 10 | "test:watch": "npm run test -- --singleRun=false", 11 | "lint": "eslint --ext .jsx ./src ./demos", 12 | "demo": "webpack-dev-server --config webpack.demo.config.js --content-base demo/", 13 | "prepublish": "npm run build" 14 | }, 15 | "author": "nygardk", 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/woltapp/react-geoinput" 20 | }, 21 | "keywords": [ 22 | "react", 23 | "component", 24 | "react-component", 25 | "geo", 26 | "geocoding", 27 | "geodestination", 28 | "geolocation", 29 | "input", 30 | "google", 31 | "maps" 32 | ], 33 | "peerDependencies": { 34 | "react": "^15.0.0 || ^16.0.0", 35 | "react-dom": "^15.0.0 || ^16.0.0" 36 | }, 37 | "devDependencies": { 38 | "babel-cli": "^6.26.0", 39 | "babel-core": "^6.26.0", 40 | "babel-eslint": "^8.0.1", 41 | "babel-loader": "^7.1.2", 42 | "babel-plugin-transform-class-properties": "^6.24.1", 43 | "babel-plugin-transform-object-rest-spread": "6.26.0", 44 | "babel-plugin-transform-runtime": "6.23.0", 45 | "babel-polyfill": "^6.26.0", 46 | "babel-preset-es2015": "^6.24.1", 47 | "babel-preset-react": "^6.24.1", 48 | "babel-preset-stage-1": "^6.24.1", 49 | "babel-register": "^6.26.0", 50 | "cross-env": "^5.1.0", 51 | "css-loader": "^0.28.7", 52 | "eslint": "^4.9.0", 53 | "eslint-config-airbnb": "^15.1.0", 54 | "eslint-loader": "^1.9.0", 55 | "eslint-plugin-import": "^2.8.0", 56 | "eslint-plugin-jsx-a11y": "^6.0.2", 57 | "eslint-plugin-react": "^7.4.0", 58 | "postcss-cssnext": "^3.0.2", 59 | "postcss-loader": "^2.0.8", 60 | "react": "^15.6.1", 61 | "react-dom": "^15.6.1", 62 | "style-loader": "^0.19.0", 63 | "webpack": "^3.8.1", 64 | "webpack-dev-server": "^2.9.3" 65 | }, 66 | "dependencies": { 67 | "classnames": "2.2.5", 68 | "prop-types": "^15.5.10", 69 | "react-debounce-input": "^3.1.0", 70 | "react-display-name": "^0.2.3" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-cssnext'), 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /src/DefaultGeoInput.css: -------------------------------------------------------------------------------- 1 | .geoDestionation { 2 | margin-top: 10px; 3 | } 4 | 5 | .loadingGeoDestination { 6 | margin-top: 10px; 7 | } 8 | 9 | .geoDestinationTable { 10 | border: 1px solid lightgrey; 11 | } 12 | 13 | .geoDestinationTable td { 14 | padding: 5px; 15 | } 16 | -------------------------------------------------------------------------------- /src/DefaultGeoInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import cx from 'classnames'; 4 | 5 | import GeoAddressInput from './GeoAddressInput'; 6 | import styles from './DefaultGeoInput.css'; 7 | 8 | export const formatLatLng = (coordinates, precision = 4) => 9 | `${coordinates.lat.toFixed(precision)}°N ${coordinates.lng.toFixed(precision)}° E`; 10 | 11 | const rawVal = val => val; 12 | 13 | const geoDestinationFields = [ 14 | { 15 | label: 'Street', 16 | name: 'street', 17 | render: rawVal, 18 | }, 19 | { 20 | label: 'Street Number', 21 | name: 'streetNumber', 22 | render: rawVal, 23 | }, 24 | { 25 | label: 'Subpremise', 26 | name: 'subpremise', 27 | render: rawVal, 28 | }, 29 | { 30 | label: 'City', 31 | name: 'city', 32 | render: rawVal, 33 | }, 34 | { 35 | label: 'Country', 36 | name: 'country', 37 | render: rawVal, 38 | }, 39 | { 40 | label: 'Postal Code', 41 | name: 'postalCode', 42 | render: rawVal, 43 | }, 44 | { 45 | label: 'Coordinates', 46 | name: 'location', 47 | render: formatLatLng, 48 | }, 49 | ]; 50 | 51 | const DefaultGeoInput = ({ 52 | className, 53 | addressInput, 54 | geoDestinationInput, 55 | geoDestinationClassName, 56 | geoDestinationTableClassName, 57 | loadingGeoDestination, 58 | loadingElement, 59 | predictions, 60 | activeIndex, 61 | onPredictionClick, 62 | style, 63 | }) => ( 64 |
65 | 72 | 73 | {!!loadingGeoDestination && loadingElement} 74 | 75 | {!loadingGeoDestination && geoDestinationInput.value && ( 76 |
77 | 78 | 79 | {geoDestinationFields.map(field => ( 80 | 81 | 84 | 85 | 90 | 91 | ))} 92 | 93 |
82 | {field.label} 83 | 86 | {geoDestinationInput.value[field.name] 87 | ? field.render(geoDestinationInput.value[field.name]) 88 | : '-'} 89 |
94 |
95 | )} 96 |
97 | ); 98 | 99 | DefaultGeoInput.propTypes = { 100 | activeIndex: PropTypes.number, 101 | addressInput: PropTypes.object.isRequired, 102 | className: PropTypes.string, 103 | geoDestinationInput: PropTypes.object.isRequired, 104 | geoDestinationClassName: PropTypes.string, 105 | geoDestinationTableClassName: PropTypes.string, 106 | loadingElement: PropTypes.node, 107 | loadingGeoDestination: PropTypes.bool, 108 | onPredictionClick: PropTypes.func.isRequired, 109 | predictions: PropTypes.array.isRequired, 110 | style: PropTypes.object, 111 | }; 112 | 113 | DefaultGeoInput.defaultProps = { 114 | loadingElement: ( 115 |
116 | Loading geolocation... 117 |
118 | ), 119 | }; 120 | 121 | export default DefaultGeoInput; 122 | -------------------------------------------------------------------------------- /src/GeoAddressInput.css: -------------------------------------------------------------------------------- 1 | .container {} 2 | 3 | .predictiveInput {} 4 | -------------------------------------------------------------------------------- /src/GeoAddressInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import cx from 'classnames'; 4 | 5 | import PredictiveInput from './PredictiveInput'; 6 | import styles from './GeoAddressInput.css'; 7 | 8 | const GeoAddressInput = ({ 9 | className, 10 | containerClassName, 11 | predictions, 12 | activeIndex, 13 | onChange, 14 | onPredictionClick, 15 | style, 16 | ...rest 17 | }) => ( 18 | ({ 25 | id: index, 26 | label: `${prediction.structured_formatting.main_text}, 27 | ${prediction.structured_formatting.secondary_text}`, 28 | onClick: () => onPredictionClick(index), 29 | })) : undefined} 30 | /> 31 | ); 32 | 33 | GeoAddressInput.propTypes = { 34 | activeIndex: PropTypes.number, 35 | className: PropTypes.string, 36 | containerClassName: PropTypes.string, 37 | onPredictionClick: PropTypes.func.isRequired, 38 | onChange: PropTypes.func.isRequired, 39 | predictions: PropTypes.array.isRequired, 40 | style: PropTypes.object, 41 | }; 42 | 43 | export default GeoAddressInput; 44 | -------------------------------------------------------------------------------- /src/PredictiveInput.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: inline-block; 3 | position: relative; 4 | } 5 | 6 | .input {} 7 | 8 | .predictions { 9 | width: 100%; 10 | position: absolute; 11 | top: 100%; 12 | margin-top: 3px; 13 | 14 | background-color: white; 15 | border-radius: 3px; 16 | box-shadow: 0 1px 3px 0 rgba(32, 32, 36, 0.12), 0 2px 4px 0 rgba(32, 32, 36, 0.08); 17 | } 18 | 19 | .prediction { 20 | padding: 4px 8px; 21 | cursor: pointer; 22 | } 23 | 24 | .predictions:not(:hover) .activePrediction, .prediction:hover { 25 | background-color: #eee; 26 | } 27 | -------------------------------------------------------------------------------- /src/PredictiveInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import cx from 'classnames'; 4 | import DebounceInput from 'react-debounce-input'; 5 | 6 | import styles from './PredictiveInput.css'; 7 | 8 | const PredictiveInput = ({ 9 | activePredictionId, 10 | className, 11 | debounceTimeout, 12 | containerClassName, 13 | containerStyle, 14 | predictions, 15 | predictionsClassName, 16 | predictionItemClassName, 17 | ...rest 18 | }) => ( 19 |
20 | 25 | 26 | {predictions && !!predictions.length && ( 27 |
28 | {predictions.map(prediction => ( 29 |
36 | {prediction.label} 37 |
38 | ))} 39 |
40 | )} 41 |
42 | ); 43 | 44 | PredictiveInput.propTypes = { 45 | className: PropTypes.string, 46 | containerClassName: PropTypes.string, 47 | containerStyle: PropTypes.object, 48 | debounceTimeout: PropTypes.number, 49 | activePredictionId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 50 | predictions: PropTypes.arrayOf(PropTypes.shape({ 51 | id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 52 | label: PropTypes.node, 53 | onClick: PropTypes.func, 54 | })), 55 | predictionsClassName: PropTypes.string, 56 | predictionItemClassName: PropTypes.string, 57 | }; 58 | 59 | PredictiveInput.defaultProps = { 60 | debounceTimeout: 300, 61 | }; 62 | 63 | export default PredictiveInput; 64 | -------------------------------------------------------------------------------- /src/createGeoInput.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/forbid-prop-types, react/no-find-dom-node, react/sort-comp */ 2 | import React, { PureComponent } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { findDOMNode } from 'react-dom'; 5 | import getDisplayName from 'react-display-name'; 6 | 7 | import { geocodeByAddress } from './utils'; 8 | 9 | if (!window.google) { 10 | throw new Error('Google API has to be included. `google` must be a global object.'); 11 | } 12 | 13 | const STATUS_AUTOCOMPLETE_OK = window.google.maps.places.PlacesServiceStatus.OK; 14 | 15 | const KEYMAP = { 16 | ARROW_UP: 38, 17 | ARROW_DOWN: 40, 18 | ENTER_KEY: 13, 19 | ESC_KEY: 27, 20 | }; 21 | 22 | const getAddressFieldFromGeoDestination = (fieldName, geoDestination) => { 23 | const field = geoDestination.address_components 24 | .find(component => component.types.includes(fieldName)); 25 | return field ? field.long_name : undefined; 26 | }; 27 | 28 | const defaultOptions = { 29 | serializePrediction: prediction => `${prediction.description}`, 30 | serializeGeoDestination: geoDestination => ({ 31 | street: getAddressFieldFromGeoDestination('route', geoDestination), 32 | streetNumber: getAddressFieldFromGeoDestination('street_number', geoDestination), 33 | subpremise: getAddressFieldFromGeoDestination('subpremise', geoDestination), 34 | city: getAddressFieldFromGeoDestination('locality', geoDestination) || 35 | getAddressFieldFromGeoDestination('postal_town', geoDestination), 36 | country: getAddressFieldFromGeoDestination('country', geoDestination), 37 | postalCode: getAddressFieldFromGeoDestination('postal_code', geoDestination), 38 | // if location is empty it'll be "_.Q" object 39 | location: geoDestination.geometry && geoDestination.geometry.location 40 | ? { lat: geoDestination.geometry.location.lat(), lng: geoDestination.geometry.location.lng() } 41 | : undefined, 42 | viewport: geoDestination.geometry.viewport, 43 | }), 44 | }; 45 | 46 | function createGeoInput(WrappedInput, opts) { 47 | const options = { ...defaultOptions, ...opts }; 48 | 49 | return class GeoInput extends PureComponent { 50 | static displayName = `GeoInput(${getDisplayName(WrappedInput)})`; 51 | 52 | static propTypes = { 53 | addressInput: PropTypes.shape({ 54 | onChange: PropTypes.func.isRequired, 55 | value: PropTypes.any, 56 | }).isRequired, 57 | geoDestinationInput: PropTypes.shape({ 58 | onChange: PropTypes.func.isRequired, 59 | }).isRequired, 60 | onPredictionsLoadError: PropTypes.func, 61 | onPredictionSelect: PropTypes.func, 62 | mapsApiOptions: PropTypes.shape({ 63 | bounds: PropTypes.object, 64 | componentRestrictions: PropTypes.object, 65 | location: PropTypes.object, 66 | offset: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 67 | radius: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 68 | types: PropTypes.array, 69 | }), 70 | } 71 | 72 | state = { 73 | error: false, 74 | selectedPrediction: undefined, 75 | activeIndex: undefined, 76 | showPredictions: false, 77 | predictions: [], 78 | loadingGeoDestination: false, 79 | } 80 | 81 | componentDidMount() { 82 | this.autocompleteService = new window.google.maps.places.AutocompleteService(); 83 | document.addEventListener('click', this.onDocumentClick, false); 84 | } 85 | 86 | componentWillUnmount() { 87 | document.removeEventListener('click', this.onDocumentClick, false); 88 | } 89 | 90 | onDocumentClick = (event) => { 91 | if (!findDOMNode(this).contains(event.target)) { 92 | this.setState({ predictions: [] }); 93 | } 94 | } 95 | 96 | loadPredictions = (input) => { 97 | const { 98 | mapsApiOptions, 99 | onPredictionsLoadError, 100 | } = this.props; 101 | 102 | if (!input) { 103 | this.setState({ predictions: [] }); 104 | return; 105 | } 106 | 107 | this.autocompleteService.getPlacePredictions({ ...mapsApiOptions, input }, 108 | (predictions, status) => { 109 | if (status !== STATUS_AUTOCOMPLETE_OK) { 110 | if (onPredictionsLoadError) { 111 | onPredictionsLoadError(status); 112 | } 113 | 114 | this.setState({ 115 | error: true, 116 | predictions: [], 117 | activeIndex: undefined, 118 | selectedPrediction: undefined, 119 | }); 120 | } else { 121 | this.setState({ 122 | error: false, 123 | predictions, 124 | activeIndex: undefined, 125 | selectedPrediction: undefined, 126 | }); 127 | } 128 | }); 129 | } 130 | 131 | loadGeoDestination = (address) => { 132 | const { addressInput, geoDestinationInput } = this.props; 133 | 134 | const addressValue = address || addressInput.value; 135 | 136 | if (addressValue) { 137 | this.setState({ loadingGeoDestination: true }); 138 | 139 | geocodeByAddress(addressValue).then((results) => { 140 | const result = results && results.length 141 | ? options.serializeGeoDestination(results[0]) 142 | : {}; 143 | 144 | this.setState( 145 | { loadingGeoDestination: false }, 146 | () => geoDestinationInput.onChange(result), 147 | ); 148 | }, () => { 149 | this.setState( 150 | { loadingGeoDestination: false }, 151 | () => geoDestinationInput.onChange({}), 152 | ); 153 | }); 154 | } 155 | } 156 | 157 | onPredictionSelect = (prediction) => { 158 | const { 159 | addressInput, 160 | onPredictionSelect, 161 | } = this.props; 162 | 163 | addressInput.onChange(options.serializePrediction(prediction)); 164 | 165 | if (onPredictionSelect) { 166 | onPredictionSelect(prediction); 167 | } 168 | 169 | this.setState({ selectedPrediction: prediction }); 170 | } 171 | 172 | submitAddress = () => { 173 | this.setState({ activeIndex: undefined, predictions: [] }); 174 | 175 | this.loadGeoDestination(); 176 | } 177 | 178 | handleInputChange = (event) => { 179 | this.props.addressInput.onChange(event.target.value); 180 | this.loadPredictions(event.target.value); 181 | } 182 | 183 | handleKeyEnter = (event) => { 184 | event.preventDefault(); 185 | 186 | const { activeIndex, predictions, selectedPrediction } = this.state; 187 | 188 | this.setState({ activeIndex: undefined, predictions: [] }); 189 | 190 | if (activeIndex !== undefined && activeIndex >= 0) { 191 | this.onPredictionSelect(predictions[activeIndex]); 192 | } else if (selectedPrediction) { 193 | this.onPredictionSelect(selectedPrediction); 194 | } 195 | 196 | this.loadGeoDestination(); 197 | } 198 | 199 | handleKeyEsc = (event) => { 200 | event.stopPropagation(); 201 | 202 | this.setState({ predictions: [] }); 203 | } 204 | 205 | handleKeyDown = () => { 206 | const { addressInput } = this.props; 207 | const { activeIndex, predictions } = this.state; 208 | 209 | if (!predictions || !predictions.length) { 210 | return; 211 | } 212 | 213 | if (activeIndex === undefined || activeIndex < predictions.length - 1) { 214 | const newActiveIndex = activeIndex === undefined ? 0 : activeIndex + 1; 215 | addressInput.onChange(options.serializePrediction(predictions[newActiveIndex])); 216 | this.setState({ activeIndex: newActiveIndex }); 217 | this.onPredictionSelect(predictions[newActiveIndex]); 218 | } 219 | } 220 | 221 | handleKeyUp = () => { 222 | const { activeIndex, predictions } = this.state; 223 | 224 | if (!predictions || !predictions.length) { 225 | return; 226 | } 227 | 228 | if (activeIndex > 0) { 229 | const newActiveIndex = activeIndex - 1; 230 | this.setState({ activeIndex: newActiveIndex }); 231 | this.onPredictionSelect(predictions[newActiveIndex]); 232 | } 233 | } 234 | 235 | handleInputKeyDown = (event) => { 236 | switch (event.keyCode) { 237 | case KEYMAP.ENTER_KEY: 238 | this.handleKeyEnter(event); 239 | break; 240 | case KEYMAP.ARROW_DOWN: 241 | this.handleKeyDown(event); 242 | break; 243 | case KEYMAP.ARROW_UP: 244 | this.handleKeyUp(event); 245 | break; 246 | case KEYMAP.ESC_KEY: 247 | this.handleKeyEsc(event); 248 | break; 249 | default: 250 | break; 251 | } 252 | } 253 | 254 | onPredictionClick = (index) => { 255 | const { predictions } = this.state; 256 | 257 | if (isNaN(index) || index > predictions.length - 1 || index < 0) { 258 | throw new Error('A valid prediction index must be given as 1st argument for `onPredictionClick`'); 259 | } 260 | 261 | const selectedPrediction = predictions[index]; 262 | 263 | this.setState({ activeIndex: undefined, predictions: [] }); 264 | this.onPredictionSelect(selectedPrediction); 265 | this.loadGeoDestination(options.serializePrediction(selectedPrediction)); 266 | } 267 | 268 | render() { 269 | return ( 270 | 284 | ); 285 | } 286 | }; 287 | } 288 | 289 | export default createGeoInput; 290 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export createGeoInput from './createGeoInput'; 2 | export GeoAddressInput from './GeoAddressInput'; 3 | export PredictiveInput from './PredictiveInput'; 4 | export DefaultGeoInput from './DefaultGeoInput'; 5 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const geocoder = new window.google.maps.Geocoder(); 2 | const STAUTS_GEOCODER_OK = window.google.maps.GeocoderStatus.OK; 3 | 4 | const geocodePromisify = callback => (...args) => new Promise((resolve, reject) => { 5 | callback(...args, (results, status) => { 6 | if (status !== STAUTS_GEOCODER_OK) { 7 | reject(status); 8 | } 9 | 10 | resolve(results); 11 | }); 12 | }); 13 | 14 | export const geocode = geocodePromisify(geocoder.geocode); 15 | export const geocodeByAddress = address => geocodePromisify(geocoder.geocode)({ address }); 16 | export const geocodeByPlaceId = placeId => geocodePromisify(geocoder.geocode)({ placeId }); 17 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | devtool: 'source-map', 6 | output: { 7 | publicPath: 'lib/', 8 | path: path.resolve(__dirname, 'lib'), 9 | filename: 'react-geoinput.js', 10 | sourceMapFilename: 'react-geoinput.js.map', 11 | library: 'react-geoinput', 12 | libraryTarget: 'umd', 13 | umdNamedDefine: true, 14 | }, 15 | externals: { 16 | 'react': 'react', 17 | 'react-dom': 'react-dom', 18 | 'react-redux': 'react-redux', 19 | 'redux-saga': 'redux-saga', 20 | 'react-debounce-input': 'react-debounce-input', 21 | 'react-display-name': 'react-display-name', 22 | 'classnames': 'classnames', 23 | 'prop-types': 'prop-types', 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | enforce: 'pre', 29 | test: /\.js$/, 30 | loader: 'eslint-loader', 31 | exclude: /node_modules/, 32 | }, 33 | { 34 | test: /\.js$/, 35 | loader: 'babel-loader', 36 | query: { 37 | plugins: ['transform-runtime'], 38 | }, 39 | exclude: /node_modules/, 40 | }, 41 | { 42 | test: /\.css$/, 43 | loader: [ 44 | { loader: 'style-loader' }, 45 | { 46 | loader: 'css-loader', 47 | options: { 48 | modules: true, 49 | localIdentName: 'react-geoinput___[name]__[local]', 50 | }, 51 | }, 52 | { loader: 'postcss-loader' }, 53 | ], 54 | exclude: /node_modules/, 55 | }, 56 | ], 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /webpack.demo.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | devtool: process.env !== 'PRODUCTION' ? '#cheap-module-source-map' : false, 5 | entry: { 6 | '1_quickstart': [ 7 | 'babel-polyfill', 8 | './demos/1_quickstart/index.js', 9 | ], 10 | '2_barebones': [ 11 | 'babel-polyfill', 12 | './demos/2_barebones/index.js', 13 | ], 14 | }, 15 | resolve: { 16 | alias: { 17 | 'react-geoinput': path.resolve(__dirname, 'src/index.js'), 18 | }, 19 | }, 20 | devServer: { 21 | contentBase: path.join(__dirname, 'demos'), 22 | }, 23 | output: { 24 | filename: '[name]/bundle.js', 25 | publicPath: '/', 26 | path: path.resolve(__dirname, 'demos'), 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | enforce: 'pre', 32 | test: /\.js$/, 33 | loader: 'eslint-loader', 34 | exclude: /node_modules/, 35 | }, 36 | { 37 | test: /\.js$/, 38 | loader: 'babel-loader', 39 | exclude: /node_modules/, 40 | }, 41 | { 42 | test: /\.css$/, 43 | loader: [ 44 | { loader: 'style-loader' }, 45 | { 46 | loader: 'css-loader', 47 | options: { 48 | modules: true, 49 | localIdentName: '[name]__[local]___[hash:base64:5]', 50 | }, 51 | }, 52 | { loader: 'postcss-loader' }, 53 | ], 54 | exclude: /node_modules/, 55 | }, 56 | ], 57 | }, 58 | }; 59 | --------------------------------------------------------------------------------