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

yext / search-ui-react / 20830346539

08 Jan 2026 08:13PM UTC coverage: 85.844% (-2.0%) from 87.818%
20830346539

Pull #599

github

web-flow
Merge b0546e37d into 952c37d1e
Pull Request #599: Merge main (v1.12.0) into develop

1015 of 1380 branches covered (73.55%)

Branch coverage included in aggregate %.

117 of 121 new or added lines in 42 files covered. (96.69%)

2 existing lines in 1 file now uncovered.

2211 of 2378 relevant lines covered (92.98%)

138.12 hits per line

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

73.86
/src/components/MapboxMap.tsx
1
import React, { useRef, useEffect, useState, useCallback } from 'react';
6✔
2
import mapboxgl, { MarkerOptions } from 'mapbox-gl';
6✔
3
import { Result, useSearchState, SelectableStaticFilter } from '@yext/search-headless-react';
6✔
4
import { useDebouncedFunction } from '../hooks/useDebouncedFunction';
6✔
5
import _ from 'lodash';
6✔
6

7
import ReactDOM from 'react-dom';
6✔
8
// Try to statically import createRoot, will be undefined for <18.
9
import * as ReactDomClient from 'react-dom/client';
6✔
10

11
type LegacyReactDOM = {
12
  render?: (element: React.ReactElement, container: Element) => void;
13
  unmountComponentAtNode?: (container: Element | DocumentFragment) => boolean;
14
};
15

16
type RootHandle = {
17
  render: (children: React.ReactNode) => void;
18
  unmount: () => void;
19
};
20

21
const legacyReactDOM = ReactDOM as LegacyReactDOM;
10✔
22
const reactMajorVersion = Number(React.version.split('.')[0]);
10✔
23
const supportsCreateRoot = !Number.isNaN(reactMajorVersion) && reactMajorVersion >= 18;
10✔
24

25

26
/**
27
 * Props for rendering a custom marker on the map.
28
 *
29
 * @public
30
 */
31
export type PinComponentProps<T> = {
32
  /** The index of the pin. */
33
  index: number,
34
  /** The Mapbox map. */
35
  mapbox: mapboxgl.Map,
36
  /** The search result corresponding to the pin. */
37
  result: Result<T>,
38
  /** Where the pin is selected. */
39
  selected?: boolean,
40
};
41

42
/**
43
 * A functional component that can be used to render a custom marker on the map.
44
 *
45
 * @public
46
 */
47
export type PinComponent<T> = (props: PinComponentProps<T>) => React.JSX.Element;
48

49
/**
50
 * A function use to derive a result's coordinate.
51
 *
52
 * @public
53
 */
54
export type CoordinateGetter<T> = (result: Result<T>) => Coordinate | undefined;
55

56
/**
57
 * Coordinate use to represent the result's location on a map.
58
 *
59
 * @public
60
 */
61
export interface Coordinate {
62
  /** The latitude of the location. */
63
  latitude: number,
64
  /** The longitude of the location. */
65
  longitude: number
66
}
67

68
/**
69
 * A function which is called when user drags or zooms the map.
70
 *
71
 * @public
72
 */
73
export type OnDragHandler = (center: mapboxgl.LngLat, bounds: mapboxgl.LngLatBounds) => void;
74

75
/**
76
 * Props for the {@link MapboxMap} component.
77
 * The type param "T" represents the type of "rawData" field of the results use in the map.
78
 *
79
 * @public
80
 */
81
export interface MapboxMapProps<T> {
82
  /** Mapbox access token. */
83
  mapboxAccessToken: string,
84
  /** Interface for map customization derived from Mapbox GL's Map options. */
85
  mapboxOptions?: Omit<mapboxgl.MapboxOptions, 'container'>,
86
  /**
87
   * Custom Pin component to render for markers on the map.
88
   * By default, the built-in marker image from Mapbox GL is used.
89
   * This prop should not be used with
90
   * {@link MapboxMapProps.renderPin | renderPin}. If both are provided,
91
   * only PinComponent will be used.
92
   */
93
  PinComponent?: PinComponent<T>,
94
  /**
95
   * Render function for a custom marker on the map. This function takes in an
96
   * HTML element and is responible for rendering the pin into that element,
97
   * which will be used as the marker.
98
   * By default, the built-in marker image from Mapbox GL is used.
99
   * This prop should not be used with
100
   * {@link MapboxMapProps.PinComponent | PinComponent}. If both are provided,
101
   * only PinComponent will be used.
102
   */
103
  renderPin?: (props: PinComponentProps<T> & { container: HTMLElement }) => void,
104
  /**
105
   * A function to derive a result's coordinate for the corresponding marker's location on the map.
106
   * By default, "yextDisplayCoordinate" field is used as the result's display coordinate.
107
   */
108
  getCoordinate?: CoordinateGetter<T>,
109
  /** {@inheritDoc OnDragHandler} */
110
  onDrag?: OnDragHandler,
111
  /**
112
   * The window object of the iframe where the map should rendered. Must have mapboxgl loaded.
113
   * If not provided or mapboxgl not loaded, the map will be rendered in the parent window.
114
   */
115
  iframeWindow?: Window,
116
  /**
117
   * If set to true, the map will update its options when the mapboxOptions prop changes.
118
   * Otherwise, the map will not update its options once initially set.
119
   */
120
  allowUpdates?: boolean,
121
  /** A function that handles a pin click event. */
122
  onPinClick?: (result: Result<T> | undefined) => void,
123
  /** The options to apply to the map markers based on whether it is selected. */
124
  markerOptionsOverride?: (selected: boolean) => MarkerOptions,
125
}
126

127
/**
128
 * A component that renders a map with markers to show result locations using Mapbox GL.
129
 *
130
 * @remarks
131
 * For the map to work properly, be sure to include Mapbox GL stylesheet in the application.
132
 *
133
 * @example
134
 * For instance, user may add the following import statement in their application's index file
135
 * or in the file where `MapboxMap` is used:
136
 * `import 'mapbox-gl/dist/mapbox-gl.css';`
137
 *
138
 * Or, user may add a stylesheet link in their html page:
139
 * `<link href="https://api.mapbox.com/mapbox-gl-js/v2.9.2/mapbox-gl.css" rel="stylesheet" />`
140
 *
141
 * @param props - {@link MapboxMapProps}
142
 * @returns A React element containing a Mapbox Map
143
 *
144
 * @public
145
 */
146
export function MapboxMap<T>({
6✔
147
  mapboxAccessToken,
148
  mapboxOptions,
149
  PinComponent,
150
  renderPin,
151
  getCoordinate = getDefaultCoordinate,
18✔
152
  onDrag,
153
  iframeWindow,
154
  allowUpdates = false,
19✔
155
  onPinClick,
156
  markerOptionsOverride,
157
}: MapboxMapProps<T>): React.JSX.Element {
158
  const mapboxInstance = (iframeWindow as Window & { mapboxgl?: typeof mapboxgl })?.mapboxgl ?? mapboxgl;
19✔
159
  useEffect(() => {
13✔
160
    mapboxInstance.accessToken = mapboxAccessToken;
10✔
161
  }, [mapboxAccessToken]);
162

163
  const mapContainer = useRef<HTMLDivElement>(null);
19✔
164
  const map = useRef<mapboxgl.Map | null>(null);
19✔
165
  const markers = useRef<mapboxgl.Marker[]>([]);
19✔
166
  const markerRoots = useRef(new Map<HTMLElement, RootHandle>());
19✔
167
  const activeMarkerElements = useRef(new Set<HTMLElement>());
19✔
168
  const markerData = useRef<Array<{ marker: mapboxgl.Marker, result: Result<T>, index: number }>>([]);
19✔
169

170
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
19✔
171
  const staticFilters = useSearchState(state => state.filters?.static);
19✔
172
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
19✔
173
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
19✔
174

175
  const handlePinClick = useCallback((result: Result<T>) => {
13✔
176
    setSelectedResult(prev => prev === result ? undefined : result)
3!
177
  }, [])
178

179
  useEffect(() => {
19✔
180
    onPinClick?.(selectedResult);
13✔
181
  }, [selectedResult])
182

183
  const cleanupPinComponent = useCallback((element: HTMLElement) => {
19✔
184
    activeMarkerElements.current.delete(element);
13✔
185
    if (supportsCreateRoot) {
13!
186
      const root = markerRoots.current.get(element);
10✔
187
      if (root) {
13!
188
        root.unmount();
13✔
189
        markerRoots.current.delete(element);
13✔
190
      }
191
    } else {
192
      legacyReactDOM.unmountComponentAtNode?.(element);
13✔
193
    }
194
  }, []);
195

196
  const attachPinComponent = useCallback((element: HTMLElement, component: React.JSX.Element) => {
19✔
197
    if (supportsCreateRoot && typeof ReactDomClient.createRoot === 'function') {
14!
198
      // Use React 18+ API
199
      let root = markerRoots.current.get(element);
7✔
200
      if (!root) {
14✔
201
        root = ReactDomClient.createRoot(element);
14✔
202
        markerRoots.current.set(element, root);
14✔
203
      }
204
      root.render(component);
14✔
205
    } else if (typeof legacyReactDOM.render === 'function') {
13!
206
      // Fallback for React <18
207
      legacyReactDOM.render(component, element);
13✔
208
    }
209
  }, []);
210

211
  const removeMarkers = useCallback(() => {
19✔
212
    markers.current.forEach(marker => {
24✔
213
      if (!marker) {
16!
214
        return;
16✔
215
      }
216
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
10!
217
      if (element) {
13!
218
        cleanupPinComponent(element);
13✔
219
      }
220
      if (typeof marker.remove === 'function') {
13!
221
        marker.remove();
13✔
222
      }
223
    });
224
    markers.current = [];
24✔
225
    markerData.current = [];
24✔
226
  }, [cleanupPinComponent]);
227

228
  const locale = useSearchState(state => state.meta?.locale);
19✔
229
  // keep track of the previous value of mapboxOptions across renders
230
  const prevMapboxOptions = useRef(mapboxOptions);
19✔
231

232
  const localizeMap = useCallback(() => {
19✔
233
    const mapbox = map.current;
8✔
234
    if (!mapbox || !locale) return;
18✔
235

236
    const localizeLabels = () => {
13✔
237
      mapbox.getStyle().layers.forEach(layer => {
13✔
238
        if (layer.type === "symbol" && layer.layout?.["text-field"]) {
13!
239
          mapbox.setLayoutProperty(
13✔
240
            layer.id,
241
            "text-field",
242
            [
243
              'coalesce',
244
              ['get', `name_${getMapboxLanguage(locale)}`],
245
              ['get', 'name']
246
            ]
247
          );
248
        }
249
      });
250
    }
251

252
    if (mapbox.isStyleLoaded()) {
13!
253
      localizeLabels();
13✔
254
    } else {
255
      mapbox.once("styledata", () => localizeLabels())
×
256
    }
257
  }, [locale]);
258

259
  useEffect(() => {
13✔
260
    if (mapContainer.current) {
13✔
261
      if (map.current && allowUpdates) {
13!
262
        // Compare current and previous mapboxOptions using deep equality
263
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
×
264
          // Update to existing Map
265
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
266
          prevMapboxOptions.current = (mapboxOptions);
×
267
        }
268
      } else if (!map.current && mapboxInstance) {
13✔
269
        const options: mapboxgl.MapboxOptions = {
16✔
270
          container: mapContainer.current,
271
          style: 'mapbox://styles/mapbox/streets-v11',
272
          center: [-74.005371, 40.741611],
273
          zoom: 9,
274
          ...mapboxOptions
275
        };
276
        map.current = new mapboxInstance.Map(options);
10✔
277
        const mapbox = map.current;
16✔
278
        mapbox.resize();
10✔
279
        const nav = new mapboxInstance.NavigationControl({
16✔
280
          showCompass: false,
281
          showZoom: true,
282
          visualizePitch: false
283
        });
284
        mapbox.addControl(nav, 'top-right');
10✔
285
        if (onDragDebounced) {
10✔
286
          mapbox.on('drag', () => {
10✔
287
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
288
          });
289
          mapbox.on('zoom', (e) => {
10✔
290
            if (e.originalEvent) {
111!
291
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
292
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
293
            }
294
          });
295
          return () => {
10✔
296
            mapbox.off('drag', () => {
8✔
297
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
298
            });
299
            mapbox.off('zoom', (e) => {
8✔
300
              if (e.originalEvent) {
×
301
                onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
302
              }
303
            });
304
          };
305
        }
306
      }
307
      localizeMap();
5✔
308
    }
309
  }, [mapboxOptions, onDragDebounced, localizeMap]);
310

311
  useEffect(() => {
13✔
312
    if (iframeWindow && map.current) {
13!
313
      map.current.resize();
×
314
    }
315
  }, [mapContainer.current]);
316

317
  useEffect(() => {
13✔
318
    removeMarkers();
10✔
319
    const mapbox = map.current;
16✔
320
    if (mapbox && locationResults) {
10✔
321
      if (locationResults.length > 0) {
15!
322
        const bounds = new mapboxInstance.LngLatBounds();
15✔
323
        locationResults.forEach((result, i) => {
15✔
324
          const markerLocation = getCoordinate(result);
21✔
325
          if (markerLocation) {
16✔
326
            const { latitude, longitude } = markerLocation;
19✔
327
            const el = document.createElement('div');
19✔
328
            let markerOptions: mapboxgl.MarkerOptions = {};
19✔
329
            if (PinComponent) {
16✔
330
              if (renderPin) {
11!
331
                console.warn(
11✔
332
                  'Found both PinComponent and renderPin props. Using PinComponent.'
333
                );
334
              }
335
              attachPinComponent(el, (
11✔
336
                <PinComponent
337
                  index={i}
338
                  mapbox={mapbox}
339
                  result={result}
340
                  selected={selectedResult === result}
341
                />
342
              ));
343
              markerOptions.element = el;
11✔
344
            } else if (renderPin) {
14✔
345
              renderPin({ index: i, mapbox, result, container: el });
10✔
346
              markerOptions.element = el;
10✔
347
            }
348

349
            if (markerOptionsOverride) {
16!
UNCOV
350
              markerOptions = {
×
351
                ...markerOptions,
352
                ...markerOptionsOverride(selectedResult === result)
353
              }
354
            }
355

356
            const marker = new mapboxInstance.Marker(markerOptions)
19✔
357
              .setLngLat({ lat: latitude, lng: longitude })
358
              .addTo(mapbox);
359

360
            marker?.getElement().addEventListener('click', () => handlePinClick(result));
16✔
361
            markers.current.push(marker);
16✔
362
            if (marker) {
16✔
363
              markerData.current.push({ marker, result, index: i });
16✔
364
            }
365
            bounds.extend([longitude, latitude]);
16✔
366
          }
367
        })
368

369
        const canvas = mapbox.getCanvas();
15✔
370
        if (!bounds.isEmpty() && !!canvas && canvas.height > 0 && canvas.width > 0) {
15✔
371
          const resolvedOptions = {
10✔
372
            // these settings are defaults and will be overriden if present on fitBoundsOptions
373
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
374
            maxZoom: mapboxOptions?.maxZoom ?? 15,
20✔
375
            ...mapboxOptions?.fitBoundsOptions,
376
          };
377

378
          let resolvedPadding;
379
          if (typeof resolvedOptions.padding === 'number') {
10!
380
            resolvedPadding = { top: resolvedOptions.padding, bottom: resolvedOptions.padding, left: resolvedOptions.padding, right: resolvedOptions.padding };
10✔
381
          } else {
382
            resolvedPadding = {
10✔
383
              top: resolvedOptions.padding?.top ?? 0,
10!
384
              bottom: resolvedOptions.padding?.bottom ?? 0,
10!
385
              left: resolvedOptions.padding?.left ?? 0,
10!
386
              right: resolvedOptions.padding?.right ?? 0
10!
387
            };
388
          }
389

390
          // Padding must not exceed the map's canvas dimensions
391
          const verticalPaddingSum = resolvedPadding.top + resolvedPadding.bottom;
10✔
392
          if (verticalPaddingSum >= canvas.height) {
10!
NEW
393
            const ratio = canvas.height / (verticalPaddingSum || 1);
×
394
            resolvedPadding.top = Math.max(0, resolvedPadding.top * ratio - 1);
10✔
395
            resolvedPadding.bottom = Math.max(0, resolvedPadding.bottom * ratio - 1);
10✔
396
          }
397
          const horizontalPaddingSum = resolvedPadding.left + resolvedPadding.right;
10✔
398
          if (horizontalPaddingSum >= canvas.width) {
10!
NEW
399
            const ratio = canvas.width / (horizontalPaddingSum || 1);
×
400
            resolvedPadding.left = Math.max(0, resolvedPadding.left * ratio - 1);
10✔
401
            resolvedPadding.right = Math.max(0, resolvedPadding.right * ratio - 1);
10✔
402
          }
403
          resolvedOptions.padding = resolvedPadding;
10✔
404
          mapbox.fitBounds(bounds, resolvedOptions);
10✔
405
        }
406

407
        return () => {
15✔
408
          markers.current.forEach((marker, i) => {
15✔
409
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
13✔
410
          });
411
          removeMarkers();
15✔
412
        }
413
      } else if (staticFilters?.length) {
×
414
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
415
        if (locationFilterValue) {
×
416
          mapbox.flyTo({
×
417
            center: locationFilterValue
418
          });
419
        }
420
      };
421
    }
422
  }, [PinComponent, getCoordinate, locationResults, removeMarkers, markerOptionsOverride, renderPin]);
423

424
  useEffect(() => {
13✔
425
    const mapbox = map.current;
19✔
426
    if (!mapbox || !PinComponent) {
13✔
427
      return;
9✔
428
    }
429
    markerData.current.forEach(({ marker, result, index }) => {
4✔
430
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
4!
431
      if (!element) {
4!
NEW
432
        return;
×
433
      }
434
      attachPinComponent(element, (
4✔
435
        <PinComponent
436
          index={index}
437
          mapbox={mapbox}
438
          result={result}
439
          selected={selectedResult === result}
440
        />
441
      ));
442
    });
443
  }, [attachPinComponent, PinComponent, selectedResult]);
444

445
  return (
13✔
446
    <div ref={mapContainer} className='h-full w-full' />
447
  );
448
}
449

450
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
451
  if (mapboxOptions?.style) {
×
452
    currentMap.setStyle(mapboxOptions.style);
×
453
  }
454
  // Add more options to update as needed
455
}
456

457
function isCoordinate(data: unknown): data is Coordinate {
458
  return typeof data == 'object'
16✔
459
    && typeof data?.['latitude'] === 'number'
460
    && typeof data?.['longitude'] === 'number';
461
}
462

463
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
464
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
20✔
465
  if (!yextDisplayCoordinate) {
16!
466
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
467
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
468
    return undefined;
1✔
469
  }
470
  if (!isCoordinate(yextDisplayCoordinate)) {
16!
471
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
472
    return undefined;
1✔
473
  }
474
  return yextDisplayCoordinate;
16✔
475
}
476

477
export function getMapboxLanguage(locale: string) {
6✔
478
  try {
22✔
479
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
480
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
481
  } catch (e) {
UNCOV
482
    console.warn(`Locale "${locale}" is not supported.`)
×
483
  }
484
  return 'en';
×
485
}
486

487
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
488
  const locationFilter = staticFilters.find(f => f.filter['fieldId'] === 'builtin.location' && f.filter['value'])?.filter;
×
489
  if (locationFilter) {
×
490
    const {lat, lng} = locationFilter['value'];
×
491
    return [lng, lat];
×
492
  }
493
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc