From c5ea6452d909bc646b3c0584cba14bb3ecf29c78 Mon Sep 17 00:00:00 2001 From: FlorianK13 Date: Fri, 20 Mar 2026 15:29:32 +0100 Subject: [PATCH 1/6] Improve ux of search bar #582 --- public/locales/de/translation.json | 4 +- public/locales/en/translation.json | 4 +- src/features/map/components/SearchField.jsx | 146 ++++++++++++++------ 3 files changed, 109 insertions(+), 45 deletions(-) diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index e256bdd4..ceb9f627 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -12,7 +12,9 @@ "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" }, "about": { "title": "Über openpv.de", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index fdc9fcf3..db0e34a9 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -12,7 +12,9 @@ "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" }, "about": { "title": "About the Project", diff --git a/src/features/map/components/SearchField.jsx b/src/features/map/components/SearchField.jsx index b2040085..cd33a40b 100644 --- a/src/features/map/components/SearchField.jsx +++ b/src/features/map/components/SearchField.jsx @@ -1,5 +1,6 @@ -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' @@ -10,11 +11,12 @@ export default function SearchField({ callback }) { // isSelectedAdress is used so that if an adress is already selected, // the autocomplete does stop to run const [isSelectedAdress, setIsSelectedAdress] = useState(false) + const [isFetching, setIsFetching] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) const suggestionsRef = useRef([]) const inputRef = useRef() const formRef = useRef() const [focusedIndex, setFocusedIndex] = useState(-1) - window.searchFieldInput = inputValue const { t } = useTranslation() useEffect(() => { @@ -31,7 +33,7 @@ export default function SearchField({ callback }) { document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('touchstart', handleClickOutside) } - }) + }, []) useEffect(() => { const fetchSuggestions = async () => { @@ -44,6 +46,7 @@ export default function SearchField({ callback }) { return } if (inputValue.length > 2) { + setIsFetching(true) try { const inputValueParts = inputValue.split(' ') let streetAddressNumber = null @@ -71,29 +74,30 @@ 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) => { + let suggestion = feature.properties.name + if (streetAddressNumber) { + suggestion += ' ' + streetAddressNumber + } + suggestion += + ', ' + + feature.properties.postcode + + ' ' + + feature.properties.city + return suggestion + }) + 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) @@ -102,21 +106,33 @@ export default function SearchField({ callback }) { const handleSubmit = async (event) => { event.preventDefault() - const locations = await processAddress(inputValue) - console.warn(locations) - callback(locations) + setIsSubmitting(true) + try { + const locations = await processAddress(inputValue) + callback(locations) + } finally { + setIsSubmitting(false) + } } const handleSuggestionClick = (suggestion) => { setInputValue(suggestion) processAddress(suggestion).then((locations) => { - console.warn(locations) callback(locations) }) setSuggestions([]) + setSuggestionsVisible(false) setIsSelectedAdress(true) } + const handleClear = () => { + setInputValue('') + setSuggestions([]) + setSuggestionsVisible(false) + setIsSelectedAdress(false) + inputRef.current?.focus() + } + const handleKeyDown = (event) => { if (event.key === 'ArrowDown') { event.preventDefault() @@ -140,6 +156,24 @@ 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)} + onKeyDown={handleKeyDown} + autoComplete='street-address' + disabled={isSubmitting} + role='combobox' + aria-expanded={suggestionsVisible} + aria-autocomplete='list' + aria-haspopup='listbox' + /> + @@ -174,6 +220,7 @@ export default function SearchField({ callback }) { {suggestionsVisible && ( - {suggestions.map((suggestion, index) => ( + {suggestions.length === 0 ? ( (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'} + color={'gray.500'} > - {suggestion} + {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} + + )) + )} )} From fe3bca188622b51e7cca19bd275c60216a9221a3 Mon Sep 17 00:00:00 2001 From: FlorianK13 Date: Fri, 20 Mar 2026 15:43:09 +0100 Subject: [PATCH 2/6] Deactivate auto completion #582 --- src/features/map/components/SearchField.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/map/components/SearchField.jsx b/src/features/map/components/SearchField.jsx index cd33a40b..a8c2c8b7 100644 --- a/src/features/map/components/SearchField.jsx +++ b/src/features/map/components/SearchField.jsx @@ -199,7 +199,7 @@ export default function SearchField({ callback }) { placeholder={t('searchField.placeholder')} onChange={(evt) => setInputValue(evt.target.value)} onKeyDown={handleKeyDown} - autoComplete='street-address' + autoComplete='off' disabled={isSubmitting} role='combobox' aria-expanded={suggestionsVisible} From 9005bc5d381162a7b4ffb5a83f7e020bfd5ab098 Mon Sep 17 00:00:00 2001 From: FlorianK13 Date: Fri, 20 Mar 2026 15:49:09 +0100 Subject: [PATCH 3/6] Add error message for invalid adress #582 --- src/features/map/components/SearchField.jsx | 27 ++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/features/map/components/SearchField.jsx b/src/features/map/components/SearchField.jsx index a8c2c8b7..4dbdd13f 100644 --- a/src/features/map/components/SearchField.jsx +++ b/src/features/map/components/SearchField.jsx @@ -13,6 +13,7 @@ export default function SearchField({ callback }) { const [isSelectedAdress, setIsSelectedAdress] = useState(false) const [isFetching, setIsFetching] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(false) const suggestionsRef = useRef([]) const inputRef = useRef() const formRef = useRef() @@ -107,22 +108,31 @@ export default function SearchField({ callback }) { const handleSubmit = async (event) => { event.preventDefault() setIsSubmitting(true) + setSubmitError(false) try { const locations = await processAddress(inputValue) - callback(locations) + if (locations.length === 0) { + setSubmitError(true) + } else { + callback(locations) + } } finally { setIsSubmitting(false) } } - const handleSuggestionClick = (suggestion) => { + const handleSuggestionClick = async (suggestion) => { setInputValue(suggestion) - processAddress(suggestion).then((locations) => { - callback(locations) - }) setSuggestions([]) setSuggestionsVisible(false) setIsSelectedAdress(true) + setSubmitError(false) + const locations = await processAddress(suggestion) + if (locations.length === 0) { + setSubmitError(true) + } else { + callback(locations) + } } const handleClear = () => { @@ -197,7 +207,7 @@ export default function SearchField({ callback }) { ref={inputRef} value={inputValue} placeholder={t('searchField.placeholder')} - onChange={(evt) => setInputValue(evt.target.value)} + onChange={(evt) => { setInputValue(evt.target.value); setSubmitError(false) }} onKeyDown={handleKeyDown} autoComplete='off' disabled={isSubmitting} @@ -217,6 +227,11 @@ export default function SearchField({ callback }) { {t('Search')}
+ {submitError && ( +
+ {t('noSearchResults.description')} +
+ )} {suggestionsVisible && ( Date: Fri, 20 Mar 2026 15:59:50 +0100 Subject: [PATCH 4/6] Add info to provide house number #582 --- public/locales/de/translation.json | 3 +- public/locales/en/translation.json | 3 +- src/features/map/components/SearchField.jsx | 64 ++++++++++++++------- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index ceb9f627..3339e5ab 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -14,7 +14,8 @@ "searchField": { "placeholder": "Geben Sie Ihre Adresse oder Koordinaten ein - z.B. Lange Point 20, Freising", "clear": "Eingabe löschen", - "noResults": "Keine Vorschläge gefunden" + "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 db0e34a9..711f9c05 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -14,7 +14,8 @@ "searchField": { "placeholder": "Enter your address or coordinates - e.g. Lange Point 20, Freising", "clear": "Clear input", - "noResults": "No suggestions found" + "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 4dbdd13f..ba9ff941 100644 --- a/src/features/map/components/SearchField.jsx +++ b/src/features/map/components/SearchField.jsx @@ -14,6 +14,7 @@ export default function SearchField({ callback }) { 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() @@ -77,16 +78,15 @@ export default function SearchField({ callback }) { const data = await response.json() const fetchedSuggestions = data.features.map((feature) => { - let suggestion = feature.properties.name + const streetName = feature.properties.name + const postcode = feature.properties.postcode + const city = feature.properties.city + let display = streetName if (streetAddressNumber) { - suggestion += ' ' + streetAddressNumber + display += ' ' + streetAddressNumber } - suggestion += - ', ' + - feature.properties.postcode + - ' ' + - feature.properties.city - return suggestion + display += ', ' + postcode + ' ' + city + return { display, streetName, postcode, city, houseNumber: streetAddressNumber } }) setSuggestions(fetchedSuggestions) setSuggestionsVisible(true) @@ -121,17 +121,35 @@ export default function SearchField({ callback }) { } } - const handleSuggestionClick = async (suggestion) => { - setInputValue(suggestion) - setSuggestions([]) - setSuggestionsVisible(false) - setIsSelectedAdress(true) - setSubmitError(false) - const locations = await processAddress(suggestion) - if (locations.length === 0) { - setSubmitError(true) + const handleSuggestionClick = (suggestion) => { + 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) + setIsSelectedAdress(true) + setSubmitError(false) + setNeedsHouseNumber(false) + processAddress(fullAddress).then((locations) => { + if (locations.length === 0) setSubmitError(true) + else callback(locations) + }) } else { - callback(locations) + // No house number yet — ask user to type it + const newValue = `${streetName} , ${postcode} ${city}` + const cursorPos = streetName.length + 1 + setInputValue(newValue) + setSuggestions([]) + setSuggestionsVisible(false) + setIsSelectedAdress(true) + setSubmitError(false) + setNeedsHouseNumber(true) + setTimeout(() => { + inputRef.current?.focus() + inputRef.current?.setSelectionRange(cursorPos, cursorPos) + }, 0) } } @@ -140,6 +158,7 @@ export default function SearchField({ callback }) { setSuggestions([]) setSuggestionsVisible(false) setIsSelectedAdress(false) + setNeedsHouseNumber(false) inputRef.current?.focus() } @@ -207,7 +226,7 @@ export default function SearchField({ callback }) { ref={inputRef} value={inputValue} placeholder={t('searchField.placeholder')} - onChange={(evt) => { setInputValue(evt.target.value); setSubmitError(false) }} + onChange={(evt) => { setInputValue(evt.target.value); setSubmitError(false); setNeedsHouseNumber(false) }} onKeyDown={handleKeyDown} autoComplete='off' disabled={isSubmitting} @@ -227,6 +246,11 @@ export default function SearchField({ callback }) { {t('Search')} + {needsHouseNumber && ( +
+ {t('searchField.enterHouseNumber')} +
+ )} {submitError && (
{t('noSearchResults.description')} @@ -272,7 +296,7 @@ export default function SearchField({ callback }) { role='option' aria-selected={focusedIndex === index} > - {suggestion} + {suggestion.display} )) )} From e093fc094b96b7ad290a2d602f70fa44a9dfb12a Mon Sep 17 00:00:00 2001 From: FlorianK13 Date: Fri, 20 Mar 2026 16:10:19 +0100 Subject: [PATCH 5/6] Refactor code #582 --- src/features/map/components/SearchField.jsx | 35 ++++++++++----------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/features/map/components/SearchField.jsx b/src/features/map/components/SearchField.jsx index ba9ff941..36b6a75b 100644 --- a/src/features/map/components/SearchField.jsx +++ b/src/features/map/components/SearchField.jsx @@ -8,9 +8,9 @@ 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) @@ -42,9 +42,9 @@ export default function SearchField({ callback }) { 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) { @@ -59,7 +59,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 } @@ -103,14 +103,13 @@ export default function SearchField({ callback }) { const debounceTimer = setTimeout(fetchSuggestions, 200) return () => clearTimeout(debounceTimer) - }, [inputValue, isSelectedAdress]) + }, [inputValue, isSelectedAddress]) - const handleSubmit = async (event) => { - event.preventDefault() + const submitAddress = async (address) => { setIsSubmitting(true) setSubmitError(false) try { - const locations = await processAddress(inputValue) + const locations = await processAddress(address) if (locations.length === 0) { setSubmitError(true) } else { @@ -121,6 +120,11 @@ export default function SearchField({ callback }) { } } + const handleSubmit = (event) => { + event.preventDefault() + submitAddress(inputValue) + } + const handleSuggestionClick = (suggestion) => { const { streetName, postcode, city, houseNumber } = suggestion if (houseNumber) { @@ -129,13 +133,9 @@ export default function SearchField({ callback }) { setInputValue(fullAddress) setSuggestions([]) setSuggestionsVisible(false) - setIsSelectedAdress(true) - setSubmitError(false) + setIsSelectedAddress(true) setNeedsHouseNumber(false) - processAddress(fullAddress).then((locations) => { - if (locations.length === 0) setSubmitError(true) - else callback(locations) - }) + submitAddress(fullAddress) } else { // No house number yet — ask user to type it const newValue = `${streetName} , ${postcode} ${city}` @@ -143,8 +143,7 @@ export default function SearchField({ callback }) { setInputValue(newValue) setSuggestions([]) setSuggestionsVisible(false) - setIsSelectedAdress(true) - setSubmitError(false) + setIsSelectedAddress(true) setNeedsHouseNumber(true) setTimeout(() => { inputRef.current?.focus() @@ -157,7 +156,7 @@ export default function SearchField({ callback }) { setInputValue('') setSuggestions([]) setSuggestionsVisible(false) - setIsSelectedAdress(false) + setIsSelectedAddress(false) setNeedsHouseNumber(false) inputRef.current?.focus() } From f39bace447ab38af9ea1a3566481b6853725f00f Mon Sep 17 00:00:00 2001 From: FlorianK13 Date: Fri, 20 Mar 2026 16:11:14 +0100 Subject: [PATCH 6/6] Run formatter #582 --- src/features/map/components/SearchField.jsx | 39 +++++++++++++-------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/features/map/components/SearchField.jsx b/src/features/map/components/SearchField.jsx index 36b6a75b..2f0132a6 100644 --- a/src/features/map/components/SearchField.jsx +++ b/src/features/map/components/SearchField.jsx @@ -1,4 +1,11 @@ -import { Button, IconButton, Input, InputGroup, List, Spinner } 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' @@ -86,7 +93,13 @@ export default function SearchField({ callback }) { display += ' ' + streetAddressNumber } display += ', ' + postcode + ' ' + city - return { display, streetName, postcode, city, houseNumber: streetAddressNumber } + return { + display, + streetName, + postcode, + city, + houseNumber: streetAddressNumber, + } }) setSuggestions(fetchedSuggestions) setSuggestionsVisible(true) @@ -184,11 +197,7 @@ export default function SearchField({ callback }) { } }, [focusedIndex]) - const startElement = isFetching ? ( - - ) : ( - - ) + const startElement = isFetching ? : const endElement = inputValue.length > 0 && !isSubmitting ? ( @@ -225,7 +234,11 @@ export default function SearchField({ callback }) { ref={inputRef} value={inputValue} placeholder={t('searchField.placeholder')} - onChange={(evt) => { setInputValue(evt.target.value); setSubmitError(false); setNeedsHouseNumber(false) }} + onChange={(evt) => { + setInputValue(evt.target.value) + setSubmitError(false) + setNeedsHouseNumber(false) + }} onKeyDown={handleKeyDown} autoComplete='off' disabled={isSubmitting} @@ -246,7 +259,9 @@ export default function SearchField({ callback }) {
{needsHouseNumber && ( -
+
{t('searchField.enterHouseNumber')}
)} @@ -271,11 +286,7 @@ export default function SearchField({ callback }) { boxShadow='md' > {suggestions.length === 0 ? ( - + {t('searchField.noResults')} ) : (