feat(LocationSearch): add LocationSearch component for improved location input

This commit is contained in:
alexsparkes
2026-01-28 13:43:29 +00:00
parent 6e852eb252
commit 50aae0f8ec
7 changed files with 830 additions and 66 deletions

View File

@@ -0,0 +1,435 @@
import variables from 'config/variables';
import { memo, useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { MdExpandMore, MdCheck, MdClose, MdMyLocation } from 'react-icons/md';
import { useDebouncedCallback } from 'use-debounce';
import EventBus from 'utils/eventbus';
import './LocationSearch.scss';
const LocationSearch = memo((props) => {
const { label, name, category, placeholder, disabled } = props;
// Load location data from localStorage (new JSON format or legacy string)
const [locationData, setLocationData] = useState(() => {
const stored = localStorage.getItem(name);
if (!stored) return null;
try {
const parsed = JSON.parse(stored);
if (parsed && typeof parsed === 'object' && parsed.displayName) {
return parsed;
}
} catch {
// Legacy format: plain string city name
return { displayName: stored, legacy: true };
}
return null;
});
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
const containerRef = useRef(null);
const controlRef = useRef(null);
const menuRef = useRef(null);
const searchInputRef = useRef(null);
const abortControllerRef = useRef(null);
const closeDropdown = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsOpen(false);
setIsClosing(false);
setFocusedIndex(-1);
}, 200);
}, []);
useEffect(() => {
const handleClickOutside = (event) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target) &&
menuRef.current &&
!menuRef.current.contains(event.target)
) {
closeDropdown();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [closeDropdown]);
const itemsCount = useMemo(() => suggestions.length, [suggestions]);
const calculatePosition = useCallback(() => {
if (controlRef.current) {
const rect = controlRef.current.getBoundingClientRect();
const gap = 4;
const viewportHeight = window.innerHeight;
const estimatedMenuHeight = Math.min(Math.max(itemsCount, 1) * 56, 250);
const spaceBelow = viewportHeight - rect.bottom - gap;
const spaceAbove = rect.top - gap;
const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
return {
top: shouldFlipUp ? rect.top - gap : rect.bottom + gap,
left: rect.left,
width: rect.width,
maxHeight: shouldFlipUp ? Math.min(250, spaceAbove) : Math.min(250, spaceBelow),
flipped: shouldFlipUp,
};
}
return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false };
}, [itemsCount]);
const openDropdown = useCallback(() => {
const position = calculatePosition();
setMenuPosition(position);
setIsOpen(true);
}, [calculatePosition]);
useEffect(() => {
if (!isOpen) return;
let rafId = null;
const updatePosition = () => {
if (rafId) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(() => {
const newPosition = calculatePosition();
setMenuPosition(newPosition);
});
};
window.addEventListener('scroll', updatePosition, { passive: true });
window.addEventListener('resize', updatePosition, { passive: true });
let element = controlRef.current?.parentElement;
const scrollableElements = [];
while (element) {
const hasScrollableContent = element.scrollHeight > element.clientHeight;
const overflowYStyle = window.getComputedStyle(element).overflowY;
const isOverflowYScrollable = overflowYStyle !== 'visible' && overflowYStyle !== 'hidden';
if (hasScrollableContent && isOverflowYScrollable) {
scrollableElements.push(element);
element.addEventListener('scroll', updatePosition, { passive: true });
}
element = element.parentElement;
}
return () => {
if (rafId) window.cancelAnimationFrame(rafId);
window.removeEventListener('scroll', updatePosition);
window.removeEventListener('resize', updatePosition);
scrollableElements.forEach((el) => el.removeEventListener('scroll', updatePosition));
};
}, [isOpen, calculatePosition]);
useEffect(() => {
if (isOpen && searchInputRef.current) {
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}, [isOpen]);
// Debounced search function
const debouncedSearch = useDebouncedCallback(async (query) => {
if (query.length < 2) {
setSuggestions([]);
setIsLoading(false);
return;
}
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const response = await fetch(
`${variables.constants.API_URL}/geocode?q=${encodeURIComponent(query)}`,
{ signal: abortControllerRef.current.signal },
);
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
setSuggestions(data);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Location search error:', err);
setSuggestions([]);
}
} finally {
setIsLoading(false);
}
}, 300);
const handleSearchChange = useCallback(
(e) => {
const value = e.target.value;
setSearchQuery(value);
setIsLoading(value.length >= 2);
debouncedSearch(value);
if (!isOpen && value.length > 0) {
openDropdown();
}
},
[isOpen, debouncedSearch, openDropdown],
);
const handleInputClick = useCallback(
(e) => {
e.stopPropagation();
if (!isOpen) {
openDropdown();
}
},
[isOpen, openDropdown],
);
const selectLocation = useCallback(
(location) => {
const locationObj = {
name: location.name,
displayName: location.displayName,
lat: location.lat,
lon: location.lon,
country: location.country,
state: location.state,
};
localStorage.setItem(name, JSON.stringify(locationObj));
localStorage.removeItem('currentWeather');
setLocationData(locationObj);
setSearchQuery('');
setSuggestions([]);
closeDropdown();
EventBus.emit('refresh', category);
document.querySelector('.reminder-info').style.display = 'flex';
localStorage.setItem('showReminder', true);
},
[name, category, closeDropdown],
);
const handleAutoLocation = useCallback(() => {
setSearchQuery(variables.getMessage('modals.main.loading'));
setSuggestions([]);
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
const data = await (
await fetch(
`${variables.constants.API_URL}/gps?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}`,
)
).json();
if (data && data[0]) {
const loc = data[0];
const locationObj = {
name: loc.name,
displayName: [loc.name, loc.state, loc.country].filter(Boolean).join(', '),
lat: position.coords.latitude,
lon: position.coords.longitude,
country: loc.country,
state: loc.state,
};
localStorage.setItem(name, JSON.stringify(locationObj));
localStorage.removeItem('currentWeather');
setLocationData(locationObj);
setSearchQuery('');
EventBus.emit('refresh', category);
document.querySelector('.reminder-info').style.display = 'flex';
localStorage.setItem('showReminder', true);
}
} catch (err) {
console.error('Auto location error:', err);
setSearchQuery('');
}
},
(error) => {
console.error('Geolocation error:', error);
setSearchQuery('');
},
{ enableHighAccuracy: true },
);
}, [name, category]);
const handleKeyDown = useCallback(
(e) => {
if (disabled) return;
switch (e.key) {
case 'Enter':
e.preventDefault();
if (isOpen && focusedIndex >= 0 && suggestions[focusedIndex]) {
selectLocation(suggestions[focusedIndex]);
}
break;
case 'Escape':
if (searchQuery) {
setSearchQuery('');
setSuggestions([]);
} else {
closeDropdown();
}
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen && searchQuery.length >= 2) {
openDropdown();
} else {
setFocusedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev));
}
break;
case 'ArrowUp':
e.preventDefault();
if (isOpen) {
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
}
break;
}
},
[
isOpen,
suggestions,
focusedIndex,
disabled,
searchQuery,
openDropdown,
closeDropdown,
selectLocation,
],
);
const clearSearch = useCallback(
(e) => {
e.stopPropagation();
setSearchQuery('');
setSuggestions([]);
if (searchInputRef.current) {
searchInputRef.current.focus();
}
},
[],
);
const id = 'location-search-' + name;
const displayValue = locationData?.displayName || placeholder || 'Search location...';
return (
<div
className={`location-search ${id} ${disabled ? 'disabled' : ''}`}
ref={containerRef}
onClick={(e) => e.stopPropagation()}
>
{label && (
<div className="location-search-header">
<label className="location-search-label">{label}</label>
<span className="location-search-auto" onClick={handleAutoLocation}>
<MdMyLocation />
{variables.getMessage('modals.main.settings.sections.weather.auto')}
</span>
</div>
)}
<div
ref={controlRef}
className={`location-search-control ${isOpen || searchQuery ? 'searching' : ''}`}
onClick={(e) => {
e.stopPropagation();
if (disabled) return;
if (!isOpen) {
openDropdown();
}
}}
onKeyDown={handleKeyDown}
role="combobox"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label || name}
tabIndex={disabled ? -1 : 0}
>
<input
ref={searchInputRef}
type="text"
className="location-search-input"
placeholder={displayValue}
value={searchQuery}
onChange={handleSearchChange}
onClick={handleInputClick}
onKeyDown={handleKeyDown}
/>
{searchQuery && <MdClose className="location-search-clear" onClick={clearSearch} />}
{isLoading ? (
<span className="location-search-loading" />
) : (
<MdExpandMore className={`location-search-arrow ${isOpen ? 'open' : ''}`} />
)}
</div>
{(isOpen || isClosing) &&
createPortal(
<div
ref={menuRef}
className={`location-search-menu ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
role="listbox"
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
width: `${menuPosition.width}px`,
maxHeight: menuPosition.maxHeight ? `${menuPosition.maxHeight}px` : '250px',
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
}}
>
{suggestions.length > 0 ? (
suggestions.map((item, index) => (
<div
key={`${item.lat}-${item.lon}`}
className={`location-search-option ${index === focusedIndex ? 'focused' : ''}`}
onClick={() => selectLocation(item)}
role="option"
tabIndex={0}
>
<span className="location-search-option-text">
<span className="location-search-option-name">{item.name}</span>
{(item.state || item.country) && (
<span className="location-search-option-detail">
{[item.state, item.country].filter(Boolean).join(', ')}
</span>
)}
</span>
<MdCheck className="location-search-option-check" />
</div>
))
) : searchQuery.length >= 2 && !isLoading ? (
<div className="location-search-empty">
{variables.getMessage('widgets.weather.not_found')}
</div>
) : searchQuery.length < 2 && searchQuery.length > 0 ? (
<div className="location-search-empty">
{variables.getMessage('modals.main.settings.sections.weather.location')}...
</div>
) : null}
</div>,
document.body,
)}
</div>
);
});
LocationSearch.displayName = 'LocationSearch';
export { LocationSearch as default, LocationSearch };

View File

@@ -0,0 +1,351 @@
@use 'scss/variables' as *;
@use 'scss/mixins' as *;
@include keyframes(locationSearchSlideIn) {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@include keyframes(locationSearchSlideOut) {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-10px);
}
}
@include keyframes(locationSearchSlideInUp) {
0% {
opacity: 0;
transform: translateY(-100%) translateY(10px);
}
100% {
opacity: 1;
transform: translateY(-100%);
}
}
@include keyframes(locationSearchSlideOutUp) {
0% {
opacity: 1;
transform: translateY(-100%);
}
100% {
opacity: 0;
transform: translateY(-100%) translateY(10px);
}
}
@include keyframes(locationSearchSpin) {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.location-search {
position: relative;
width: 300px;
gap: 8px;
display: flex;
flex-flow: column;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.location-search-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.location-search-label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($subColor);
}
}
.location-search-auto {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($link);
}
&:hover {
opacity: 0.8;
}
svg {
font-size: 14px;
}
}
.location-search-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 56px;
padding: 0 16px;
cursor: text;
transition: all 0.2s ease;
outline: none;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
color: t($color);
&:hover {
border-color: t($color);
}
}
&:focus-within {
@include themed {
border-color: t($link);
box-shadow: 0 0 0 3px rgba(t($link), 0.2);
}
}
}
.location-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
height: 100%;
font-size: 14px;
padding: 0;
min-width: 0;
@include themed {
color: t($color);
&::placeholder {
color: t($color);
opacity: 1;
}
}
}
.location-search-clear {
flex-shrink: 0;
font-size: 20px;
cursor: pointer;
transition: all 0.2s ease;
padding: 4px;
border-radius: 50%;
@include themed {
color: t($subColor);
&:hover {
background: t($modal-sidebarActive);
color: t($color);
}
}
}
.location-search-loading {
width: 20px;
height: 20px;
border: 2px solid transparent;
border-radius: 50%;
@include animation(locationSearchSpin 0.8s linear infinite);
@include themed {
border-top-color: t($link);
border-right-color: t($link);
}
}
.location-search-arrow {
flex-shrink: 0;
font-size: 24px;
transition: all 0.2s ease;
cursor: pointer;
padding: 4px;
border-radius: 50%;
margin: -4px;
@include themed {
color: t($subColor);
&:hover {
background: t($modal-sidebarActive);
color: t($color);
}
}
&.open {
transform: rotate(180deg);
}
}
}
.location-search-menu {
max-height: 250px;
overflow-y: auto;
overflow-x: hidden;
z-index: 9999;
@include animation(locationSearchSlideIn 0.2s ease-out);
will-change: transform, opacity;
@include themed {
background: t($modal-background);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&.flipped {
@include animation(locationSearchSlideInUp 0.2s ease-out);
&.closing {
@include animation(locationSearchSlideOutUp 0.2s ease-out forwards);
}
}
&.closing:not(.flipped) {
@include animation(locationSearchSlideOut 0.2s ease-out forwards);
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
@include themed {
background: t($modal-sidebar);
}
}
&::-webkit-scrollbar-thumb {
@include themed {
background: t($modal-sidebarActive);
border-radius: 3px;
}
&:hover {
@include themed {
background: t($color);
}
}
}
}
.location-search-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
@include themed {
color: t($color);
&:hover {
background: t($modal-sidebarActive);
padding-left: 20px;
}
&.focused {
background: t($modal-sidebarActive);
border-left: 2px solid t($link);
}
}
.location-search-option-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.location-search-option-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.location-search-option-detail {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@include themed {
color: t($subColor);
}
}
.location-search-option-check {
flex-shrink: 0;
font-size: 14px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
@include themed {
background: t($link);
color: white;
}
}
&:hover .location-search-option-check {
opacity: 0.5;
}
}
.location-search-empty {
padding: 16px;
text-align: center;
font-size: 14px;
@include themed {
color: t($subColor);
}
}

View File

@@ -0,0 +1 @@
export * from './LocationSearch';

View File

@@ -2,6 +2,7 @@ export * from './Checkbox';
export * from './ChipSelect';
export * from './Dropdown';
export * from './FileUpload';
export * from './LocationSearch';
export * from './Radio';
export * from './SearchInput';
export * from './Slider';

View File

@@ -13,7 +13,21 @@ import { getWeather } from './api/getWeather.js';
import './weather.scss';
const WeatherWidget = memo(() => {
const [location, setLocation] = useState(localStorage.getItem('location') || 'London');
const [location, setLocation] = useState(() => {
const stored = localStorage.getItem('location');
if (!stored) return 'London';
// Try parsing as new JSON format
try {
const parsed = JSON.parse(stored);
if (parsed && typeof parsed === 'object') {
return parsed;
}
} catch {
// Legacy string format
}
return stored;
});
const [done, setDone] = useState(false);
const [weatherData, setWeatherData] = useState({});
@@ -57,10 +71,14 @@ const WeatherWidget = memo(() => {
return <WeatherSkeleton weatherType={weatherType} />;
}
// Get display name from location (handles both object and string formats)
const locationDisplay =
typeof location === 'object' ? location.displayName || location.name : location;
if (!weatherData.weather) {
return (
<div className="weather">
<span className="loc">{location}</span>
<span className="loc">{locationDisplay}</span>
</div>
);
}
@@ -87,7 +105,7 @@ const WeatherWidget = memo(() => {
amount: `${formatNumber(weatherData.weather.feels_like)}${weatherData.temp_text}`,
})}
</span>
<span className="loc">{location}</span>
<span className="loc">{locationDisplay}</span>
</div>
)}
</div>

View File

@@ -49,7 +49,19 @@ export const getWeather = async (location) => {
}
try {
const response = await fetch(variables.constants.API_URL + `/weather?city=${location}`);
// Build URL based on location type
let url;
if (typeof location === 'object' && location.lat && location.lon) {
// New format: use coordinates (preferred)
url = `${variables.constants.API_URL}/weather?lat=${location.lat}&lon=${location.lon}`;
} else {
// Legacy format: use city name string
const cityName =
typeof location === 'object' ? location.displayName || location.name : location;
url = `${variables.constants.API_URL}/weather?city=${encodeURIComponent(cityName)}`;
}
const response = await fetch(url);
if (!response.ok) {
console.error('Weather API response not ok:', response.status, response.statusText);

View File

@@ -1,59 +1,19 @@
import { useCallback } from 'react';
import { MdAutoAwesome } from 'react-icons/md';
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
import { useLocalStorageState } from 'utils/useLocalStorageState';
import { Radio, Dropdown, Checkbox } from 'components/Form/Settings';
import { Radio, Dropdown, Checkbox, LocationSearch } from 'components/Form/Settings';
import variables from 'config/variables';
const useWeatherSettings = () => {
const [location, setLocation] = useLocalStorageState('location', '');
const [windSpeed, setWindSpeed] = useLocalStorageState('windspeed', 'true');
const showReminder = useCallback(() => {
document.querySelector('.reminder-info').style.display = 'flex';
localStorage.setItem('showReminder', true);
}, []);
const changeLocation = (e) => {
localStorage.removeItem('currentWeather');
setLocation(e.target.value);
showReminder();
};
const getAutoLocation = useCallback(() => {
setLocation(variables.getMessage('modals.main.loading'));
navigator.geolocation.getCurrentPosition(
async (position) => {
const data = await (
await fetch(
`${variables.constants.API_URL}/gps?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}`,
)
).json();
setLocation(data[0].name);
showReminder();
},
(error) => {
console.error(error);
},
{
enableHighAccuracy: true,
},
);
}, [setLocation, showReminder]);
return {
location,
windSpeed: windSpeed !== 'true',
setWindSpeed,
changeLocation,
getAutoLocation,
};
};
const WeatherOptions = () => {
const { location, windSpeed, setWindSpeed, changeLocation, getAutoLocation } =
useWeatherSettings();
const { windSpeed, setWindSpeed } = useWeatherSettings();
const weatherType = localStorage.getItem('weatherType');
const WEATHER_SECTION = 'modals.main.settings.sections.weather';
@@ -81,26 +41,12 @@ const WeatherOptions = () => {
<Row>
<Content title={variables.getMessage(`${WEATHER_SECTION}.location`)} />
<Action>
<div className="text-field-container">
<div className="text-field">
<div className="text-field-header">
<label className="text-field-label">
{variables.getMessage(`${WEATHER_SECTION}.location`)}
</label>
<span className="text-field-reset" onClick={getAutoLocation}>
<MdAutoAwesome />
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
</span>
</div>
<input
type="text"
className="text-field-input"
value={location}
onChange={changeLocation}
placeholder="London"
/>
</div>
</div>
<LocationSearch
label={variables.getMessage(`${WEATHER_SECTION}.location`)}
name="location"
category="weather"
placeholder="London"
/>
</Action>
</Row>
);