-
Notifications
You must be signed in to change notification settings - Fork 129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
VACMS 20241 - autosuggest/typeahead/combobox address for facility locator #34361
base: main
Are you sure you want to change the base?
Changes from all commits
b3f0314
7b516e8
6b8db34
3c853b1
7ae7972
c1b5dab
3f4ca99
d574ffa
ae249d6
5945aaf
05ee225
dd34b56
3af99c3
0d22f8d
756953c
0a8ea22
804d501
4380f3c
6c42a7d
2a41ec9
6f54195
642624d
c6bf05e
343b85d
f6f4c6a
e3bd272
6d6c48c
c38edd2
679e3ff
3359518
877ad6a
ef87190
c7cce5c
676679e
2b53dd8
93542bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -281,6 +281,7 @@ | |
"dotenv": "^10.0.0", | ||
"downloadjs": "^1.4.7", | ||
"downshift": "^1.22.5", | ||
"downshift-v9": "npm:downshift@^9.0.8", | ||
"express": "^4.17.1", | ||
"fast-levenshtein": "^2.0.6", | ||
"fine-uploader": "^5.16.2", | ||
|
@@ -452,4 +453,4 @@ | |
] | ||
}, | ||
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,208 @@ | ||
import React, { useCallback, useEffect, useState } from 'react'; | ||
import vaDebounce from 'platform/utilities/data/debounce'; | ||
import recordEvent from 'platform/monitoring/record-event'; | ||
import PropTypes from 'prop-types'; | ||
import UseMyLocation from './UseMyLocation'; | ||
import AddressInputError from './AddressInputError'; | ||
import { searchAddresses } from '../utils/mapHelpers'; | ||
import Autosuggest from './Autosuggest'; | ||
|
||
const MIN_SEARCH_CHARS = 3; | ||
const onlySpaces = str => /^\s+$/.test(str); | ||
|
||
function AddressAutosuggest({ | ||
currentQuery, | ||
geolocateUser, | ||
inputRef, | ||
onClearClick, | ||
onChange, | ||
}) { | ||
const [inputValue, setInputValue] = useState(null); | ||
const { locationChanged, searchString, geolocationInProgress } = currentQuery; | ||
const [selectedItem, setSelectedItem] = useState(null); | ||
const [options, setOptions] = useState([]); | ||
const [showAddressError, setShowAddressError] = useState(false); | ||
const [isTouched, setIsTouched] = useState(false); | ||
const [isGeocoding, setIsGeocoding] = useState(false); | ||
|
||
const inputClearClick = useCallback( | ||
() => { | ||
onClearClick(); // clears searchString in redux | ||
onChange({ searchString: '' }); | ||
setInputValue(''); | ||
// setting to null causes the the searchString to be used, because of a useEffect below | ||
// so we set it to a non-existent object | ||
setSelectedItem(null); | ||
setOptions([]); | ||
}, | ||
[onClearClick, onChange], | ||
); | ||
|
||
const handleOnSelect = item => { | ||
// This selects the WHOLE item not just the text to display giving you access to all it's data | ||
if (!item || item.disabled) { | ||
// just do nothing if it's disabled -- could also clear the input or do whatever | ||
return; | ||
} | ||
setSelectedItem(item); | ||
onChange({ | ||
searchString: onlySpaces(item.toDisplay) | ||
? item.toDisplay.trim() | ||
: item.toDisplay, | ||
}); | ||
}; | ||
|
||
/** | ||
* updateSearch | ||
* @param {string} term | ||
* @returns {void} | ||
* updateSearch is not called directly but debounced below | ||
*/ | ||
const updateSearch = term => { | ||
const trimmedTerm = term?.trim(); | ||
if (trimmedTerm === searchString?.trim()) { | ||
return; // already have the values | ||
} | ||
if (trimmedTerm.length >= MIN_SEARCH_CHARS) { | ||
// fetch results and set options | ||
setIsGeocoding(true); | ||
searchAddresses(trimmedTerm) | ||
.then(features => { | ||
if (!features) { | ||
setOptions([]); | ||
} else { | ||
setOptions([ | ||
...features.map(feature => ({ | ||
...feature, | ||
toDisplay: feature.place_name || trimmedTerm, | ||
})), | ||
]); | ||
} | ||
setIsGeocoding(false); | ||
}) | ||
.catch(() => setIsGeocoding(false)); | ||
} | ||
}; | ||
|
||
const handleGeolocationButtonClick = e => { | ||
e.preventDefault(); | ||
recordEvent({ | ||
event: 'fl-get-geolocation', | ||
}); | ||
geolocateUser(); | ||
}; | ||
|
||
const debouncedUpdateSearch = vaDebounce(500, updateSearch); | ||
|
||
const onBlur = () => { | ||
const iv = inputValue?.trim(); | ||
onChange({ searchString: ' ' }); | ||
onChange({ searchString: iv || '' }); | ||
// not expected to search when user leaves the field | ||
}; | ||
|
||
const handleInputChange = e => { | ||
const { inputValue: value } = e; | ||
setInputValue(value); | ||
setIsTouched(true); | ||
|
||
if (!value?.trim()) { | ||
onClearClick(); | ||
return; | ||
} | ||
|
||
debouncedUpdateSearch(value); | ||
}; | ||
|
||
useEffect( | ||
() => { | ||
// If the location is changed, and there is no value in searchString or inputValue then show the error | ||
setShowAddressError( | ||
locationChanged && | ||
!geolocationInProgress && | ||
!searchString?.length && | ||
!inputValue, // not null but empty string (null on start) | ||
); | ||
}, | ||
[ | ||
locationChanged, | ||
geolocationInProgress, | ||
searchString, | ||
inputValue, | ||
isTouched, | ||
], | ||
); | ||
|
||
useEffect( | ||
() => { | ||
if (searchString && !geolocationInProgress) { | ||
setInputValue(searchString); | ||
} | ||
}, | ||
[searchString, geolocationInProgress], | ||
); | ||
|
||
return ( | ||
<Autosuggest | ||
inputValue={inputValue || ''} | ||
onInputValueChange={handleInputChange} | ||
selectedItem={selectedItem || null} | ||
handleOnSelect={handleOnSelect} | ||
/* eslint-disable prettier/prettier */ | ||
label={( | ||
<> | ||
<span id="city-state-zip-text">City, state or postal code</span>{' '} | ||
<span className="form-required-span">(*Required)</span> | ||
</> | ||
)} | ||
/* eslint-enable prettier/prettier */ | ||
options={options} | ||
downshiftInputProps={{ | ||
// none are required | ||
id: 'street-city-state-zip', // not required to provide an id | ||
onFocus: () => setIsTouched(true), // not required | ||
onBlur, // override the onBlur to handle that we want to keep the data and update the search in redux | ||
disabled: false, | ||
autoCorrect: 'off', | ||
spellCheck: 'false', | ||
onChange: e => { | ||
// possibly necessary if you see input jumping around | ||
handleInputChange({ inputValue: e.target.value }); | ||
}, | ||
}} | ||
onClearClick={inputClearClick} | ||
inputError={<AddressInputError showError={showAddressError || false} />} | ||
showError={showAddressError} | ||
inputId="street-city-state-zip" | ||
inputRef={inputRef} | ||
/* eslint-disable prettier/prettier */ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ESLint disabled here |
||
labelSibling={( | ||
<UseMyLocation | ||
onClick={handleGeolocationButtonClick} | ||
geolocationInProgress={currentQuery.geolocationInProgress} | ||
/> | ||
)} | ||
/* eslint-enable prettier/prettier */ | ||
minCharacters={MIN_SEARCH_CHARS} | ||
keepDataOnBlur | ||
showDownCaret={false} | ||
shouldShowNoResults={false} | ||
isLoading={isGeocoding} | ||
loadingMessage="Searching..." | ||
/> | ||
); | ||
} | ||
|
||
AddressAutosuggest.propTypes = { | ||
onChange: PropTypes.func.isRequired, | ||
currentQuery: PropTypes.shape({ | ||
geolocationInProgress: PropTypes.bool, | ||
locationChanged: PropTypes.bool, | ||
searchString: PropTypes.string, | ||
}), | ||
geolocateUser: PropTypes.func, | ||
inputRef: PropTypes.object, | ||
onClearClick: PropTypes.func, | ||
}; | ||
|
||
export default AddressAutosuggest; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import PropTypes from 'prop-types'; | ||
import React from 'react'; | ||
|
||
function AddressInputError({ showError }) { | ||
if (!showError) { | ||
return null; | ||
} | ||
return ( | ||
<span className="usa-input-error-message" role="alert"> | ||
<span className="sr-only">Error</span> | ||
Please fill in a city, state, or postal code. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We stopped using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's being updated in the progressive disclosure ticket. We're also changing the string associated with it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can update here to be in line with that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we need to update it here to be in line with DS standards (if we know what it should be). |
||
</span> | ||
); | ||
} | ||
|
||
AddressInputError.propTypes = { | ||
showError: PropTypes.bool, | ||
}; | ||
|
||
export default AddressInputError; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import classNames from 'classnames'; | ||
|
||
export const optionClasses = selected => | ||
classNames('dropdown-option', { selected }); | ||
|
||
function AutosuggestOption({ | ||
highlightedIndex, | ||
index, | ||
item, | ||
getItemProps, | ||
itemToString, | ||
}) { | ||
return ( | ||
<p | ||
{...getItemProps({ | ||
item, | ||
className: optionClasses(index === highlightedIndex), | ||
role: 'option', | ||
'aria-selected': index === highlightedIndex, | ||
})} | ||
data-testid={`autosuggest-option-${item.id || `${item}-${index}`}`} | ||
> | ||
{itemToString(item)} | ||
</p> | ||
); | ||
} | ||
|
||
AutosuggestOption.propTypes = { | ||
getItemProps: PropTypes.func.isRequired, | ||
index: PropTypes.number.isRequired, | ||
item: PropTypes.object.isRequired, | ||
itemToString: PropTypes.func.isRequired, | ||
highlightedIndex: PropTypes.number, // something may not be higlighted - optional from Downshift | ||
}; | ||
|
||
export default AutosuggestOption; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import React, { useEffect, useState } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
|
||
function AutosuggestOptions({ | ||
getItemProps, | ||
highlightedIndex, | ||
options, | ||
isShown, | ||
isLoading, | ||
loadingMessage, | ||
itemToString, | ||
getMenuProps, | ||
noItemsMessage, | ||
shouldShowNoResults, | ||
AutosuggestOptionComponent, | ||
}) { | ||
// All an option is required to have is an id, toDisplay, and optionally disabled | ||
// Anything else is up to the user. The `id` is used for the key and that's why it is required | ||
const [optionsToShow, setOptionsToShow] = useState([]); | ||
useEffect( | ||
() => { | ||
if (isLoading) { | ||
setOptionsToShow([ | ||
{ id: 'loading', disabled: true, toDisplay: loadingMessage }, | ||
]); | ||
} else if (!options?.length && shouldShowNoResults) { | ||
setOptionsToShow([ | ||
{ id: 'no-items', disabled: true, toDisplay: noItemsMessage }, | ||
]); | ||
} else { | ||
setOptionsToShow(options); | ||
} | ||
}, | ||
[options, noItemsMessage, shouldShowNoResults, isLoading, loadingMessage], | ||
); | ||
|
||
return ( | ||
<div | ||
className="dropdown" | ||
{...getMenuProps()} | ||
style={{ display: !isShown || !optionsToShow?.length ? 'none' : 'block' }} | ||
data-testid="autosuggest-options" | ||
> | ||
{optionsToShow.map((item, index) => ( | ||
<AutosuggestOptionComponent | ||
key={item.id || `${item}-${index}`} | ||
item={item} | ||
index={index} | ||
getItemProps={getItemProps} | ||
highlightedIndex={highlightedIndex} | ||
itemToString={itemToString} | ||
/> | ||
))} | ||
</div> | ||
); | ||
} | ||
|
||
AutosuggestOptions.propTypes = { | ||
getItemProps: PropTypes.func.isRequired, | ||
getMenuProps: PropTypes.func.isRequired, | ||
isShown: PropTypes.bool.isRequired, | ||
itemToString: PropTypes.func.isRequired, | ||
noItemsMessage: PropTypes.string.isRequired, | ||
options: PropTypes.array.isRequired, | ||
shouldShowNoResults: PropTypes.bool.isRequired, | ||
AutosuggestOptionComponent: PropTypes.elementType, | ||
highlightedIndex: PropTypes.number, // something may not be higlighted - optional from Downshift | ||
isLoading: PropTypes.bool, | ||
loadingMessage: PropTypes.string, | ||
}; | ||
|
||
export default AutosuggestOptions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ESLint disabled here