Skip to content

Commit

Permalink
feat: typescript
Browse files Browse the repository at this point in the history
  • Loading branch information
ob6160 committed Jun 27, 2021
1 parent 53fafc9 commit dd091bb
Show file tree
Hide file tree
Showing 16 changed files with 1,206 additions and 0 deletions.
15 changes: 15 additions & 0 deletions src/components/Icon/Icon.tsx
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;
3 changes: 3 additions & 0 deletions src/components/Icon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Icon from './Icon';

export default Icon;
182 changes: 182 additions & 0 deletions src/components/LocationSearch/LocationSearch.tsx
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;
1 change: 1 addition & 0 deletions src/components/LocationSearch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './LocationSearch';
60 changes: 60 additions & 0 deletions src/components/LocationSearch/useNominatimAutocomplete.ts
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;
125 changes: 125 additions & 0 deletions src/components/LocationSearch/usePlacesAutocomplete.ts
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;
Loading

0 comments on commit dd091bb

Please sign in to comment.