• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

keplergl / kepler.gl / 23984814153

04 Apr 2026 06:20PM UTC coverage: 59.866% (-0.009%) from 59.875%
23984814153

push

github

web-flow
fix: geocoder coordinate search results not showing (#2245) (#3322)

When entering coordinates in the geocoder search box, the results
dropdown might not appear or could be overwritten by a pending
geocoder API call.

Fix by:
1. Clearing any pending geocoder debounce timeout when valid
   coordinates are detected, preventing stale API results from
   overwriting the coordinate result.
2. Explicitly setting showResults to true to ensure the dropdown
   is visible.

Signed-off-by: pierreeurope <pierre.europe@pm.me>

6527 of 12996 branches covered (50.22%)

Branch coverage included in aggregate %.

0 of 3 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

13324 of 20163 relevant lines covered (66.08%)

78.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

40.3
/src/components/src/geocoder/geocoder.tsx
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import React, {useCallback, useMemo, useState} from 'react';
5
import styled from 'styled-components';
6
import classnames from 'classnames';
7
import geocoderService from '@mapbox/mapbox-sdk/services/geocoding';
8
import {injectIntl, IntlShape} from 'react-intl';
9
import {WebMercatorViewport} from 'viewport-mercator-project';
10
import {KeyEvent} from '@kepler.gl/constants';
11
import {Input} from '../common/styled-components';
12
import {Search, Delete} from '../common/icons';
13
import {Viewport} from '@kepler.gl/types';
14
import {isTest} from '@kepler.gl/utils';
15

16
type StyledContainerProps = {
17
  width?: number;
18
};
19

20
// matches only valid coordinates
21
const COORDINATE_REGEX_STRING =
22
  '(^[-+]?(?:[1-8]?\\d(?:\\.\\d+)?|90(?:\\.0+)?)),\\s*([-+]?(?:180(?:\\.0+)?|(?:(?:1[0-7]\\d)|(?:[1-9]?\\d))(?:\\.\\d+)?))$';
7✔
23

24
const COORDINATE_REGEX = RegExp(COORDINATE_REGEX_STRING);
7✔
25

26
const PLACEHOLDER = 'Enter an address or coordinates, ex 37.79,-122.40';
7✔
27

28
let debounceTimeout: NodeJS.Timeout | null = null;
7✔
29

30
/**
31
 * Tests if a given query string contains valid coordinates.
32
 * @param query The input string to test for coordinates.
33
 * @returns A tuple where:
34
 *   - If valid, returns `[true, longitude, latitude]`.
35
 *   - If invalid, returns `[false, query]`.
36
 */
37
export const testForCoordinates = (query: string): [true, number, number] | [false, string] => {
7✔
38
  const isValid = COORDINATE_REGEX.test(query.trim());
6✔
39

40
  if (!isValid) {
6✔
41
    return [isValid, query];
4✔
42
  }
43

44
  const tokens = query.trim().split(',');
2✔
45
  const latitude = Number(tokens[0]);
2✔
46
  const longitude = Number(tokens[1]);
2✔
47

48
  return [isValid, longitude, latitude];
2✔
49
};
50

51
const StyledContainer = styled.div<StyledContainerProps>`
7✔
52
  position: relative;
53
  color: ${props => props.theme.textColor};
1✔
54

55
  .geocoder-input {
56
    box-shadow: ${props => props.theme.boxShadow};
1✔
57

58
    .geocoder-input__search {
59
      position: absolute;
60
      height: ${props => props.theme.geocoderInputHeight}px;
1✔
61
      width: 30px;
62
      padding-left: 6px;
63
      display: flex;
64
      align-items: center;
65
      justify-content: center;
66
      color: ${props => props.theme.subtextColor};
1✔
67
    }
68

69
    input {
70
      padding: 4px 36px;
71
      height: ${props => props.theme.geocoderInputHeight}px;
1✔
72
      caret-color: unset;
73
    }
74
  }
75

76
  .geocoder-results {
77
    box-shadow: ${props => props.theme.boxShadow};
1✔
78
    background-color: ${props => props.theme.panelBackground};
1✔
79
    position: absolute;
80
    width: ${props => (Number.isFinite(props.width) ? props.width : props.theme.geocoderWidth)}px;
1!
81
    margin-top: ${props => props.theme.dropdownWapperMargin}px;
1✔
82
  }
83

84
  .geocoder-item {
85
    ${props => props.theme.dropdownListItem};
1✔
86
    ${props => props.theme.textTruncate};
1✔
87

88
    &.active {
89
      background-color: ${props => props.theme.dropdownListHighlightBg};
1✔
90
    }
91
  }
92

93
  .remove-result {
94
    position: absolute;
95
    right: 16px;
96
    top: 0px;
97
    height: ${props => props.theme.geocoderInputHeight}px;
1✔
98
    display: flex;
99
    align-items: center;
100

101
    &:hover {
102
      cursor: pointer;
103
      color: ${props => props.theme.textColorHl};
1✔
104
    }
105
  }
106
`;
107

108
export interface Result {
109
  center: [number, number];
110
  place_name: string;
111
  bbox?: [number, number, number, number];
112
  text?: string;
113
}
114

115
export type Results = ReadonlyArray<Result>;
116

117
type GeocoderProps = {
118
  mapboxApiAccessToken: string;
119
  className?: string;
120
  limit?: number;
121
  timeout?: number;
122
  formatItem?: (item: Result) => string;
123
  viewport?: Viewport;
124
  onSelected: (viewport: Viewport | null, item: Result) => void;
125
  onDeleteMarker?: () => void;
126
  transitionDuration?: number;
127
  pointZoom?: number;
128
  width?: number;
129
};
130

131
type IntlProps = {
132
  intl: IntlShape;
133
};
134

135
const GeoCoder: React.FC<GeocoderProps & IntlProps> = ({
7✔
136
  mapboxApiAccessToken,
137
  className = '',
1✔
138
  limit = 5,
1✔
139
  timeout = 300,
1✔
140
  formatItem = item => item.place_name,
✔
141
  viewport,
142
  onSelected,
143
  onDeleteMarker,
144
  transitionDuration,
145
  pointZoom,
146
  width,
147
  intl
148
}) => {
149
  const [inputValue, setInputValue] = useState('');
1✔
150
  const [showResults, setShowResults] = useState(false);
1✔
151
  const [showDelete, setShowDelete] = useState(false);
1✔
152
  const initialResults: Result[] = [];
1✔
153
  const [results, setResults] = useState(initialResults);
1✔
154
  const [selectedIndex, setSelectedIndex] = useState(0);
1✔
155

156
  const client = useMemo(
1✔
157
    () => (isTest() ? null : geocoderService({accessToken: mapboxApiAccessToken})),
1!
158
    [mapboxApiAccessToken]
159
  );
160

161
  const onChange = useCallback(
1✔
162
    event => {
163
      const queryString = event.target.value;
×
164
      setInputValue(queryString);
×
165
      const resultCoordinates = testForCoordinates(queryString);
×
166
      if (resultCoordinates[0]) {
×
NEW
167
        if (debounceTimeout) {
×
NEW
168
          clearTimeout(debounceTimeout);
×
169
        }
170
        const [_isValid, longitude, latitude] = resultCoordinates;
×
NEW
171
        setShowResults(true);
×
UNCOV
172
        setResults([{center: [longitude, latitude], place_name: queryString}]);
×
173
      } else {
174
        if (debounceTimeout) {
×
175
          clearTimeout(debounceTimeout);
×
176
        }
177
        debounceTimeout = setTimeout(async () => {
×
178
          if (limit > 0 && Boolean(queryString)) {
×
179
            try {
×
180
              const response = await client
×
181
                .forwardGeocode({
182
                  query: queryString,
183
                  limit
184
                })
185
                .send();
186
              if (response.body.features) {
×
187
                setShowResults(true);
×
188
                setResults(response.body.features);
×
189
              }
190
            } catch (e) {
191
              // TODO: show geocode error
192
              // eslint-disable-next-line no-console
193
              console.log(e);
×
194
            }
195
          }
196
        }, timeout);
197
      }
198
    },
199
    [client, limit, timeout, setResults, setShowResults]
200
  );
201

202
  const onBlur = useCallback(() => {
1✔
203
    setTimeout(() => {
×
204
      setShowResults(false);
×
205
    }, timeout);
206
  }, [setShowResults, timeout]);
207

208
  const onFocus = useCallback(() => setShowResults(true), [setShowResults]);
1✔
209

210
  const onItemSelected = useCallback(
1✔
211
    item => {
212
      const newViewport = new WebMercatorViewport(viewport);
×
213
      const {bbox, center} = item;
×
214

215
      const gotoViewport = bbox
×
216
        ? newViewport.fitBounds([
217
            [bbox[0], bbox[1]],
218
            [bbox[2], bbox[3]]
219
          ])
220
        : {
221
            longitude: center[0],
222
            latitude: center[1],
223
            zoom: pointZoom
224
          };
225

226
      const {longitude, latitude, zoom} = gotoViewport;
×
227

228
      onSelected({...viewport, ...{longitude, latitude, zoom, transitionDuration}}, item);
×
229

230
      setShowResults(false);
×
231
      setInputValue(formatItem(item));
×
232
      setShowDelete(true);
×
233
    },
234
    [viewport, onSelected, transitionDuration, pointZoom, formatItem]
235
  );
236

237
  const onMarkDeleted = useCallback(() => {
1✔
238
    setShowDelete(false);
×
239
    setInputValue('');
×
240
    onDeleteMarker?.();
×
241
  }, [onDeleteMarker]);
242

243
  const onKeyDown = useCallback(
1✔
244
    e => {
245
      if (!results || results.length === 0) {
×
246
        return;
×
247
      }
248
      switch (e.keyCode) {
×
249
        case KeyEvent.DOM_VK_UP:
250
          setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : selectedIndex);
×
251
          break;
×
252
        case KeyEvent.DOM_VK_DOWN:
253
          setSelectedIndex(selectedIndex < results.length - 1 ? selectedIndex + 1 : selectedIndex);
×
254
          break;
×
255
        case KeyEvent.DOM_VK_ENTER:
256
        case KeyEvent.DOM_VK_RETURN:
257
          if (results[selectedIndex]) {
×
258
            onItemSelected(results[selectedIndex]);
×
259
          }
260
          break;
×
261
        default:
262
          break;
×
263
      }
264
    },
265
    [results, selectedIndex, setSelectedIndex, onItemSelected]
266
  );
267

268
  return (
1✔
269
    <StyledContainer className={className} width={width}>
270
      <div className="geocoder-input">
271
        <div className="geocoder-input__search">
272
          <Search height="20px" />
273
        </div>
274
        <Input
275
          type="text"
276
          onChange={onChange}
277
          onBlur={onBlur}
278
          onFocus={onFocus}
279
          onKeyDown={onKeyDown}
280
          value={inputValue}
281
          placeholder={
282
            intl
1!
283
              ? intl.formatMessage({id: 'geocoder.title', defaultMessage: PLACEHOLDER})
284
              : PLACEHOLDER
285
          }
286
        />
287
        {showDelete ? (
1!
288
          <div className="remove-result">
289
            <Delete height="16px" onClick={onMarkDeleted} />
290
          </div>
291
        ) : null}
292
      </div>
293

294
      {showResults ? (
1!
295
        <div className="geocoder-results">
296
          {results.map((item, index) => (
297
            <div
×
298
              key={index}
299
              className={classnames('geocoder-item', {active: selectedIndex === index})}
300
              onClick={() => onItemSelected(item)}
×
301
            >
302
              {formatItem(item)}
303
            </div>
304
          ))}
305
        </div>
306
      ) : null}
307
    </StyledContainer>
308
  );
309
};
310

311
export default injectIntl(GeoCoder);
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc