-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
16 changed files
with
1,206 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; | ||
import styled from '@emotion/styled'; | ||
import { color } from 'styled-system'; | ||
import { createPropTypes } from '@styled-system/prop-types'; | ||
|
||
const Icon = styled(FontAwesomeIcon)` | ||
${color} | ||
`; | ||
|
||
Icon.propTypes = { | ||
...createPropTypes(color.propNames), | ||
}; | ||
|
||
/** @component */ | ||
export default Icon; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import Icon from './Icon'; | ||
|
||
export default Icon; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
import React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { useCombobox } from 'downshift'; | ||
import { useTheme } from 'emotion-theming'; | ||
import styled from '@emotion/styled'; | ||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; | ||
import { faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; | ||
|
||
import Box from '../Box'; | ||
import VisuallyHidden from '../VisuallyHidden'; | ||
|
||
import useNominatimAutocomplete from './useNominatimAutocomplete'; | ||
|
||
const Input = styled.input( | ||
({ theme }) => ` | ||
border: 1px solid ${theme.colors.primary}; | ||
border-radius: ${theme.radii[4]}px; | ||
padding: ${theme.space[2]}px 3rem; | ||
padding-left: 3rem; | ||
width: 100%; | ||
` | ||
); | ||
|
||
const LocationSearch = ({ onSelectedItemChange }) => { | ||
const [query, setQuery] = React.useState(''); | ||
const theme = useTheme(); | ||
|
||
const { places, getPlaceLatLng } = useNominatimAutocomplete(query); | ||
|
||
const handleSelectedItemChange = async ({ selectedItem }) => { | ||
if (!selectedItem) { | ||
return; | ||
} | ||
window.plausible('Search'); | ||
const { lat, lng } = await getPlaceLatLng(selectedItem); | ||
|
||
onSelectedItemChange({ lat, lng }); | ||
}; | ||
|
||
const stateReducer = (state, actionAndChanges) => { | ||
switch (actionAndChanges.type) { | ||
case useCombobox.stateChangeTypes.InputBlur: | ||
// Prevents reset on blur to fix results being closed when iOS keyboard is hidden | ||
return { | ||
...actionAndChanges.changes, | ||
isOpen: state.isOpen, | ||
}; | ||
|
||
case useCombobox.stateChangeTypes.FunctionOpenMenu: | ||
// Always clear the input when opening the menu | ||
return { | ||
...actionAndChanges.changes, | ||
inputValue: '', | ||
}; | ||
|
||
case useCombobox.stateChangeTypes.ToggleButtonClick: | ||
// Clear the value when toggle button is clicked unless an item is selected | ||
return { | ||
...actionAndChanges.changes, | ||
inputValue: state.selectedItem ? state.inputValue : '', | ||
}; | ||
|
||
default: | ||
return actionAndChanges.changes; | ||
} | ||
}; | ||
|
||
const { | ||
isOpen, | ||
getLabelProps, | ||
openMenu, | ||
getToggleButtonProps, | ||
getMenuProps, | ||
getInputProps, | ||
getComboboxProps, | ||
highlightedIndex, | ||
getItemProps, | ||
} = useCombobox({ | ||
items: places, | ||
onInputValueChange: ({ inputValue }) => setQuery(inputValue), | ||
itemToString: (item) => (item ? item.label : ''), | ||
onSelectedItemChange: handleSelectedItemChange, | ||
stateReducer, | ||
}); | ||
|
||
return ( | ||
<> | ||
<VisuallyHidden> | ||
<label {...getLabelProps()}>Search for a location</label> | ||
</VisuallyHidden> | ||
|
||
<Box position="relative" {...getComboboxProps()}> | ||
<Box | ||
position="absolute" | ||
top="50%" | ||
left={3} | ||
zIndex={1} | ||
css={{ | ||
transform: 'translateY(-50%)', | ||
}} | ||
> | ||
<FontAwesomeIcon | ||
icon={faSearch} | ||
fixedWidth | ||
color={theme.colors.tertiary} | ||
/> | ||
</Box> | ||
|
||
<Input | ||
placeholder="search location…" | ||
autoComplete="off" | ||
{...getInputProps({ | ||
onFocus: () => { | ||
if (isOpen) { | ||
return; | ||
} | ||
openMenu(); | ||
}, | ||
})} | ||
/> | ||
|
||
{isOpen && ( | ||
<Box | ||
position="absolute" | ||
top="50%" | ||
right={3} | ||
css={{ | ||
transform: 'translateY(-50%)', | ||
}} | ||
> | ||
<button | ||
type="button" | ||
aria-label="close menu" | ||
{...getToggleButtonProps()} | ||
> | ||
<FontAwesomeIcon icon={faTimes} fixedWidth /> | ||
</button> | ||
</Box> | ||
)} | ||
</Box> | ||
|
||
<Box | ||
p={[3, 0]} | ||
mt={3} | ||
bg="white" | ||
borderRadius={2} | ||
display={isOpen ? undefined : 'none'} | ||
{...getMenuProps()} | ||
> | ||
{isOpen && ( | ||
<> | ||
{places.length | ||
? places.map((item, index) => ( | ||
<Box | ||
key={item.id} | ||
color={highlightedIndex === index ? 'tertiary' : undefined} | ||
py={2} | ||
border={0} | ||
borderBottom={index !== places.length - 1 ? 1 : undefined} | ||
borderStyle="solid" | ||
borderColor="lightGrey" | ||
css={{ | ||
cursor: 'pointer', | ||
}} | ||
{...getItemProps({ item, index })} | ||
> | ||
<span>{item.label}</span> | ||
</Box> | ||
)) | ||
: 'No results found'} | ||
</> | ||
)} | ||
</Box> | ||
</> | ||
); | ||
}; | ||
|
||
LocationSearch.propTypes = { | ||
onSelectedItemChange: PropTypes.func.isRequired, | ||
}; | ||
|
||
export default LocationSearch; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from './LocationSearch'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
import React from 'react'; | ||
import debounce from 'lodash/debounce'; | ||
|
||
const useNominatimAutocomplete = (input) => { | ||
const [places, setPlaces] = React.useState([]); | ||
|
||
const fetchPlaces = React.useCallback( | ||
debounce(async (input) => { | ||
const fetchUrl = `https://nominatim.openstreetmap.org/search?q=${input}&countrycodes=gb&limit=5&format=json`; | ||
|
||
const response = await fetch(fetchUrl); | ||
const results = await response.json(); | ||
|
||
if (!results) { | ||
return; | ||
} | ||
|
||
const locationResults = results.map((item) => ({ | ||
id: item.place_id, | ||
label: item.display_name, | ||
location: { | ||
lat: item.lat, | ||
lng: item.lon, | ||
}, | ||
})); | ||
|
||
setPlaces(locationResults); | ||
}, 300), | ||
[] | ||
); | ||
|
||
// Fetch places when input changes | ||
React.useEffect(() => { | ||
if (input.length < 3) { | ||
return; | ||
} | ||
|
||
fetchPlaces(input); | ||
}, [input, fetchPlaces]); | ||
|
||
// Clear places when input is cleared | ||
React.useEffect(() => { | ||
if (input) { | ||
return; | ||
} | ||
|
||
setPlaces([]); | ||
}, [input, setPlaces]); | ||
|
||
const getPlaceLatLng = ({ location }) => { | ||
return { | ||
lat: parseFloat(location.lat), | ||
lng: parseFloat(location.lng), | ||
}; | ||
}; | ||
|
||
return { places, getPlaceLatLng }; | ||
}; | ||
|
||
export default useNominatimAutocomplete; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import React from 'react'; | ||
import debounce from 'lodash/debounce'; | ||
|
||
const usePlacesAutocompleteService = () => { | ||
const autocompleteService = React.useRef(); | ||
|
||
React.useEffect(() => { | ||
if (!window.google || !window.google.maps.places) { | ||
return; | ||
} | ||
|
||
if (autocompleteService.current) { | ||
return; | ||
} | ||
|
||
autocompleteService.current = new window.google.maps.places.AutocompleteService(); | ||
}); | ||
|
||
return autocompleteService; | ||
}; | ||
|
||
// Session token batches autocomplete results together to reduce Google Maps API costs | ||
// https://developers.google.com/maps/documentation/javascript/reference/places-autocomplete-service#AutocompleteSessionToken | ||
const usePlacesSessionToken = () => { | ||
const [token, setToken] = React.useState(null); | ||
|
||
const reset = () => { | ||
setToken(new window.google.maps.places.AutocompleteSessionToken()); | ||
}; | ||
|
||
React.useEffect(() => { | ||
if (!window.google || !window.google.maps.places) { | ||
return; | ||
} | ||
|
||
if (token) { | ||
return; | ||
} | ||
|
||
reset(); | ||
}); | ||
|
||
return [token, reset]; | ||
}; | ||
|
||
const usePlacesAutocomplete = (input) => { | ||
const autocompleteService = usePlacesAutocompleteService(); | ||
|
||
const [sessionToken, resetSessionToken] = usePlacesSessionToken(); | ||
|
||
const [places, setPlaces] = React.useState([]); | ||
|
||
const fetchPlaces = React.useCallback( | ||
debounce((input) => { | ||
const onFetchCompleted = (places) => { | ||
if (!places) { | ||
return; | ||
} | ||
|
||
const locationResults = places.map((item) => ({ | ||
id: item.id, | ||
placeId: item.place_id, | ||
label: `${item.structured_formatting.main_text}, ${item.structured_formatting.secondary_text}`, | ||
})); | ||
|
||
setPlaces(locationResults); | ||
}; | ||
|
||
autocompleteService.current.getPlacePredictions( | ||
{ input, types: ['geocode'], sessionToken }, | ||
onFetchCompleted | ||
); | ||
}, 300), | ||
[sessionToken] | ||
); | ||
|
||
// Fetch places when input changes | ||
React.useEffect(() => { | ||
if (input.length < 3) { | ||
return; | ||
} | ||
|
||
fetchPlaces(input); | ||
}, [input, fetchPlaces]); | ||
|
||
// Clear places when input is cleared | ||
React.useEffect(() => { | ||
if (input) { | ||
return; | ||
} | ||
|
||
setPlaces([]); | ||
}, [input, setPlaces]); | ||
|
||
const getPlaceLatLng = ({ placeId }) => { | ||
// PlacesService expects an HTML (normally a map) element | ||
// https://developers.google.com/maps/documentation/javascript/reference/places-service#library | ||
const placesService = new window.google.maps.places.PlacesService( | ||
document.createElement('div') | ||
); | ||
|
||
const OK = window.google.maps.places.PlacesServiceStatus.OK; | ||
|
||
return new Promise((resolve, reject) => { | ||
placesService.getDetails({ placeId, sessionToken }, (result, status) => { | ||
if (status !== OK) { | ||
reject(status); | ||
return; | ||
} | ||
|
||
// Create a new session token when session has completed | ||
// https://developers.google.com/maps/documentation/javascript/reference/places-autocomplete-service#AutocompleteSessionToken | ||
resetSessionToken(); | ||
|
||
const { lat, lng } = result.geometry.location; | ||
|
||
resolve({ lat: lat(), lng: lng() }); | ||
}); | ||
}); | ||
}; | ||
|
||
return { places, getPlaceLatLng }; | ||
}; | ||
|
||
export default usePlacesAutocomplete; |
Oops, something went wrong.