diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index e256bdd4..3339e5ab 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -12,7 +12,10 @@ "close": "Schließen", "delete": "Löschen", "searchField": { - "placeholder": "Geben Sie Ihre Adresse oder Koordinaten ein - z.B. Lange Point 20, Freising" + "placeholder": "Geben Sie Ihre Adresse oder Koordinaten ein - z.B. Lange Point 20, Freising", + "clear": "Eingabe löschen", + "noResults": "Keine Vorschläge gefunden", + "enterHouseNumber": "Bitte geben Sie eine Hausnummer ein" }, "about": { "title": "Über openpv.de", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index fdc9fcf3..711f9c05 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -12,7 +12,10 @@ "close": "Close", "delete": "Delete", "searchField": { - "placeholder": "Enter your address or coordinates - e.g. Lange Point 20, Freising" + "placeholder": "Enter your address or coordinates - e.g. Lange Point 20, Freising", + "clear": "Clear input", + "noResults": "No suggestions found", + "enterHouseNumber": "Please enter a house number" }, "about": { "title": "About the Project", diff --git a/src/features/map/components/SearchField.jsx b/src/features/map/components/SearchField.jsx index b2040085..2f0132a6 100644 --- a/src/features/map/components/SearchField.jsx +++ b/src/features/map/components/SearchField.jsx @@ -1,5 +1,13 @@ -import { Button, Input, List } from '@chakra-ui/react' +import { + Button, + IconButton, + Input, + InputGroup, + List, + Spinner, +} from '@chakra-ui/react' import React, { useEffect, useRef, useState } from 'react' +import { LuSearch, LuX } from 'react-icons/lu' import { useTranslation } from 'react-i18next' import { processAddress } from '@/features/simulation/core/location' @@ -7,14 +15,17 @@ export default function SearchField({ callback }) { const [inputValue, setInputValue] = useState('') const [suggestions, setSuggestions] = useState([]) const [suggestionsVisible, setSuggestionsVisible] = useState(false) - // isSelectedAdress is used so that if an adress is already selected, + // isSelectedAddress is used so that if an adress is already selected, // the autocomplete does stop to run - const [isSelectedAdress, setIsSelectedAdress] = useState(false) + const [isSelectedAddress, setIsSelectedAddress] = useState(false) + const [isFetching, setIsFetching] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(false) + const [needsHouseNumber, setNeedsHouseNumber] = useState(false) const suggestionsRef = useRef([]) const inputRef = useRef() const formRef = useRef() const [focusedIndex, setFocusedIndex] = useState(-1) - window.searchFieldInput = inputValue const { t } = useTranslation() useEffect(() => { @@ -31,19 +42,20 @@ export default function SearchField({ callback }) { document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('touchstart', handleClickOutside) } - }) + }, []) useEffect(() => { const fetchSuggestions = async () => { if (inputValue.length < 3) { // If the input is deleted or replaced with one // charakter, the autocomplete should start again - setIsSelectedAdress(false) + setIsSelectedAddress(false) } - if (isSelectedAdress) { + if (isSelectedAddress) { return } if (inputValue.length > 2) { + setIsFetching(true) try { const inputValueParts = inputValue.split(' ') let streetAddressNumber = null @@ -54,7 +66,7 @@ export default function SearchField({ callback }) { //drop last character (ie the comma) inputPart = inputPart.slice(0, -1) } - if (inputPart.length == 5) { + if (inputPart.length === 5) { // continue if it has the length of a zip code continue } @@ -71,50 +83,95 @@ export default function SearchField({ callback }) { )}&bbox=5.98865807458,47.3024876979,15.0169958839,54.983104153&limit=5&lang=de&layer=street`, ) const data = await response.json() - console.log('data', data) - - setSuggestions( - data.features.map((feature) => { - let suggestion = feature.properties.name - if (streetAddressNumber) { - suggestion += ' ' + streetAddressNumber - } - suggestion += - ', ' + - feature.properties.postcode + - ' ' + - feature.properties.city - return suggestion - }), - ) + + const fetchedSuggestions = data.features.map((feature) => { + const streetName = feature.properties.name + const postcode = feature.properties.postcode + const city = feature.properties.city + let display = streetName + if (streetAddressNumber) { + display += ' ' + streetAddressNumber + } + display += ', ' + postcode + ' ' + city + return { + display, + streetName, + postcode, + city, + houseNumber: streetAddressNumber, + } + }) + setSuggestions(fetchedSuggestions) + setSuggestionsVisible(true) } catch (error) { console.error('Error fetching suggestions:', error) + } finally { + setIsFetching(false) } } else { setSuggestions([]) + setSuggestionsVisible(false) } - setSuggestionsVisible(suggestions.length > 0) } const debounceTimer = setTimeout(fetchSuggestions, 200) return () => clearTimeout(debounceTimer) - }, [inputValue, isSelectedAdress]) + }, [inputValue, isSelectedAddress]) + + const submitAddress = async (address) => { + setIsSubmitting(true) + setSubmitError(false) + try { + const locations = await processAddress(address) + if (locations.length === 0) { + setSubmitError(true) + } else { + callback(locations) + } + } finally { + setIsSubmitting(false) + } + } - const handleSubmit = async (event) => { + const handleSubmit = (event) => { event.preventDefault() - const locations = await processAddress(inputValue) - console.warn(locations) - callback(locations) + submitAddress(inputValue) } const handleSuggestionClick = (suggestion) => { - setInputValue(suggestion) - processAddress(suggestion).then((locations) => { - console.warn(locations) - callback(locations) - }) + const { streetName, postcode, city, houseNumber } = suggestion + if (houseNumber) { + // House number already known — fill completely and submit + const fullAddress = `${streetName} ${houseNumber}, ${postcode} ${city}` + setInputValue(fullAddress) + setSuggestions([]) + setSuggestionsVisible(false) + setIsSelectedAddress(true) + setNeedsHouseNumber(false) + submitAddress(fullAddress) + } else { + // No house number yet — ask user to type it + const newValue = `${streetName} , ${postcode} ${city}` + const cursorPos = streetName.length + 1 + setInputValue(newValue) + setSuggestions([]) + setSuggestionsVisible(false) + setIsSelectedAddress(true) + setNeedsHouseNumber(true) + setTimeout(() => { + inputRef.current?.focus() + inputRef.current?.setSelectionRange(cursorPos, cursorPos) + }, 0) + } + } + + const handleClear = () => { + setInputValue('') setSuggestions([]) - setIsSelectedAdress(true) + setSuggestionsVisible(false) + setIsSelectedAddress(false) + setNeedsHouseNumber(false) + inputRef.current?.focus() } const handleKeyDown = (event) => { @@ -140,6 +197,20 @@ export default function SearchField({ callback }) { } }, [focusedIndex]) + const startElement = isFetching ? : + + const endElement = + inputValue.length > 0 && !isSubmitting ? ( + + + + ) : null + return (
- setInputValue(evt.target.value)} - onKeyDown={handleKeyDown} + + > + { + setInputValue(evt.target.value) + setSubmitError(false) + setNeedsHouseNumber(false) + }} + onKeyDown={handleKeyDown} + autoComplete='off' + disabled={isSubmitting} + role='combobox' + aria-expanded={suggestionsVisible} + aria-autocomplete='list' + aria-haspopup='listbox' + /> +
+ {needsHouseNumber && ( +
+ {t('searchField.enterHouseNumber')} +
+ )} + {submitError && ( +
+ {t('noSearchResults.description')} +
+ )} {suggestionsVisible && ( - {suggestions.map((suggestion, index) => ( - (suggestionsRef.current[index] = elem)} - key={index} - p={2} - style={{ paddingLeft: '1em' }} - cursor='pointer' - _hover={{ backgroundColor: 'gray.100' }} - backgroundColor={focusedIndex === index ? 'gray.100' : 'white'} - onClick={() => handleSuggestionClick(suggestion)} - onKeyDown={handleKeyDown} - color={'black'} - > - {suggestion} + {suggestions.length === 0 ? ( + + {t('searchField.noResults')} - ))} + ) : ( + suggestions.map((suggestion, index) => ( + (suggestionsRef.current[index] = elem)} + key={index} + p={2} + style={{ paddingLeft: '1em' }} + cursor='pointer' + _hover={{ backgroundColor: 'gray.100' }} + backgroundColor={focusedIndex === index ? 'gray.100' : 'white'} + onClick={() => handleSuggestionClick(suggestion)} + onKeyDown={handleKeyDown} + color={'black'} + tabIndex={0} + role='option' + aria-selected={focusedIndex === index} + > + {suggestion.display} + + )) + )} )}