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

yext / search-ui-react / 21002154445

14 Jan 2026 04:41PM UTC coverage: 85.288% (-0.5%) from 85.752%
21002154445

Pull #609

github

web-flow
Merge 98f0a1ef8 into dd1cecdb8
Pull Request #609: Merge main (v2.0.0) into develop

1019 of 1401 branches covered (72.73%)

Branch coverage included in aggregate %.

104 of 127 new or added lines in 19 files covered. (81.89%)

2 existing lines in 2 files now uncovered.

2239 of 2419 relevant lines covered (92.56%)

141.1 hits per line

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

72.06
/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
  /**
233
   * Localizes Mapbox label text to a specific locale.
234
   *
235
   * Updates symbol layers that are place names such that labels prefer `name_<lang>`
236
   * (e.g. `name_fr`) and fall back to `name` when unavailable.
237
   *
238
   * Note:
239
   * - Symbol layers that are place names would have `text-field` properties that includes 'name', which are localized.
240
   * - Other symbol layers (e.g. road shields, transit, icons) are left unchanged.
241
   */
242
  const localizeMap = useCallback(() => {
19✔
243
    const mapbox = map.current;
8✔
244
    if (!mapbox || !locale) return;
18✔
245

246
    const localizeLabels = () => {
13✔
247
      mapbox.getStyle().layers.forEach(layer => {
13✔
248
        if (layer.type !== "symbol") {
13!
249
          return;
13✔
250
        }
251
        const textField = layer.layout?.["text-field"];
×
252
        if (typeof textField === "string"
13!
253
          ? textField.includes("name")
254
          : (Array.isArray(textField) && JSON.stringify(textField).includes("name"))) {
×
255
          mapbox.setLayoutProperty(
13✔
256
            layer.id,
257
            "text-field",
258
            [
259
              'coalesce',
260
              ['get', `name_${getMapboxLanguage(locale)}`],
261
              ['get', 'name']
262
            ]
263
          );
264
        }
265
      });
266
    }
267

268
    if (mapbox.isStyleLoaded()) {
13!
269
      localizeLabels();
13✔
270
    } else {
271
      mapbox.once("styledata", () => localizeLabels())
×
272
    }
273
  }, [locale]);
274

275
  useEffect(() => {
13✔
276
    if (mapContainer.current) {
13✔
277
      if (map.current && allowUpdates) {
13!
278
        // Compare current and previous mapboxOptions using deep equality
279
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
×
280
          // Update to existing Map
281
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
282
          prevMapboxOptions.current = (mapboxOptions);
×
283
        }
284
      } else if (!map.current && mapboxInstance) {
13✔
285
        const options: mapboxgl.MapboxOptions = {
16✔
286
          container: mapContainer.current,
287
          style: 'mapbox://styles/mapbox/streets-v11',
288
          center: [-74.005371, 40.741611],
289
          zoom: 9,
290
          ...mapboxOptions
291
        };
292
        map.current = new mapboxInstance.Map(options);
10✔
293
        const mapbox = map.current;
16✔
294
        mapbox.resize();
10✔
295
        const nav = new mapboxInstance.NavigationControl({
16✔
296
          showCompass: false,
297
          showZoom: true,
298
          visualizePitch: false
299
        });
300
        mapbox.addControl(nav, 'top-right');
10✔
301
        if (onDragDebounced) {
10✔
302
          mapbox.on('drag', () => {
10✔
303
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
304
          });
305
          mapbox.on('zoom', (e) => {
10✔
306
            if (e.originalEvent) {
116!
307
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
308
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
309
            }
310
          });
311
          return () => {
10✔
312
            mapbox.off('drag', () => {
8✔
313
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
314
            });
315
            mapbox.off('zoom', (e) => {
8✔
316
              if (e.originalEvent) {
×
317
                onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
318
              }
319
            });
320
          };
321
        }
322
      }
323
      localizeMap();
5✔
324
    }
325
  }, [mapboxOptions, onDragDebounced, localizeMap]);
326

327
  useEffect(() => {
13✔
328
    if (iframeWindow && map.current) {
13!
329
      map.current.resize();
×
330
    }
331
  }, [mapContainer.current]);
332

333
  useEffect(() => {
13✔
334
    removeMarkers();
10✔
335
    const mapbox = map.current;
16✔
336
    if (mapbox && locationResults) {
10✔
337
      if (locationResults.length > 0) {
15!
338
        const bounds = new mapboxInstance.LngLatBounds();
15✔
339
        locationResults.forEach((result, i) => {
15✔
340
          const markerLocation = getCoordinate(result);
21✔
341
          if (markerLocation) {
16✔
342
            const { latitude, longitude } = markerLocation;
19✔
343
            const el = document.createElement('div');
19✔
344
            let markerOptions: mapboxgl.MarkerOptions = {};
19✔
345
            if (PinComponent) {
16✔
346
              if (renderPin) {
11!
347
                console.warn(
11✔
348
                  'Found both PinComponent and renderPin props. Using PinComponent.'
349
                );
350
              }
351
              attachPinComponent(el, (
11✔
352
                <PinComponent
353
                  index={i}
354
                  mapbox={mapbox}
355
                  result={result}
356
                  selected={selectedResult === result}
357
                />
358
              ));
359
              markerOptions.element = el;
11✔
360
            } else if (renderPin) {
14✔
361
              renderPin({ index: i, mapbox, result, container: el });
10✔
362
              markerOptions.element = el;
10✔
363
            }
364

365
            if (markerOptionsOverride) {
16!
366
              markerOptions = {
×
367
                ...markerOptions,
368
                ...markerOptionsOverride(selectedResult === result)
369
              }
370
            }
371

372
            const marker = new mapboxInstance.Marker(markerOptions)
19✔
373
              .setLngLat({ lat: latitude, lng: longitude })
374
              .addTo(mapbox);
375

376
            marker?.getElement().addEventListener('click', () => handlePinClick(result));
16✔
377
            markers.current.push(marker);
16✔
378
            if (marker) {
16✔
379
              markerData.current.push({ marker, result, index: i });
16✔
380
            }
381
            bounds.extend([longitude, latitude]);
16✔
382
          }
383
        })
384

385
        const canvas = mapbox.getCanvas();
15✔
386
        if (!bounds.isEmpty() && !!canvas && canvas.height > 0 && canvas.width > 0) {
15✔
387
          const resolvedOptions = {
10✔
388
            // these settings are defaults and will be overriden if present on fitBoundsOptions
389
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
390
            maxZoom: mapboxOptions?.maxZoom ?? 15,
20✔
391
            ...mapboxOptions?.fitBoundsOptions,
392
          };
393

394
          let resolvedPadding;
395
          if (typeof resolvedOptions.padding === 'number') {
10!
396
            resolvedPadding = { top: resolvedOptions.padding, bottom: resolvedOptions.padding, left: resolvedOptions.padding, right: resolvedOptions.padding };
10✔
397
          } else {
398
            resolvedPadding = {
10✔
399
              top: resolvedOptions.padding?.top ?? 0,
10!
400
              bottom: resolvedOptions.padding?.bottom ?? 0,
10!
401
              left: resolvedOptions.padding?.left ?? 0,
10!
402
              right: resolvedOptions.padding?.right ?? 0
10!
403
            };
404
          }
405

406
          // Padding must not exceed the map's canvas dimensions
407
          const verticalPaddingSum = resolvedPadding.top + resolvedPadding.bottom;
10✔
408
          if (verticalPaddingSum >= canvas.height) {
10!
409
            const ratio = canvas.height / (verticalPaddingSum || 1);
×
410
            resolvedPadding.top = Math.max(0, resolvedPadding.top * ratio - 1);
10✔
411
            resolvedPadding.bottom = Math.max(0, resolvedPadding.bottom * ratio - 1);
10✔
412
          }
413
          const horizontalPaddingSum = resolvedPadding.left + resolvedPadding.right;
10✔
414
          if (horizontalPaddingSum >= canvas.width) {
10!
415
            const ratio = canvas.width / (horizontalPaddingSum || 1);
×
416
            resolvedPadding.left = Math.max(0, resolvedPadding.left * ratio - 1);
10✔
417
            resolvedPadding.right = Math.max(0, resolvedPadding.right * ratio - 1);
10✔
418
          }
419
          resolvedOptions.padding = resolvedPadding;
10✔
420
          mapbox.fitBounds(bounds, resolvedOptions);
10✔
421
        }
422

423
        return () => {
15✔
424
          markers.current.forEach((marker, i) => {
15✔
425
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
13✔
426
          });
427
          removeMarkers();
15✔
428
        }
429
      } else if (staticFilters?.length) {
×
430
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
431
        if (locationFilterValue) {
×
432
          mapbox.flyTo({
×
433
            center: locationFilterValue
434
          });
435
        }
436
      };
437
    }
438
  }, [PinComponent, getCoordinate, locationResults, removeMarkers, markerOptionsOverride, renderPin]);
439

440
  useEffect(() => {
13✔
441
    const mapbox = map.current;
19✔
442
    if (!mapbox || !PinComponent) {
13✔
443
      return;
9✔
444
    }
445
    markerData.current.forEach(({ marker, result, index }) => {
4✔
446
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
4!
447
      if (!element) {
4!
448
        return;
×
449
      }
450
      attachPinComponent(element, (
4✔
451
        <PinComponent
452
          index={index}
453
          mapbox={mapbox}
454
          result={result}
455
          selected={selectedResult === result}
456
        />
457
      ));
458
    });
459
  }, [attachPinComponent, PinComponent, selectedResult]);
460

461
  return (
13✔
462
    <div ref={mapContainer} className='h-full w-full' />
463
  );
464
}
465

466
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
467
  if (mapboxOptions?.style) {
×
468
    currentMap.setStyle(mapboxOptions.style);
×
469
  }
470
  // Add more options to update as needed
471
}
472

473
function isCoordinate(data: unknown): data is Coordinate {
474
  return typeof data == 'object'
16✔
475
    && typeof (data as any)?.['latitude'] === 'number'
476
    && typeof (data as any)?.['longitude'] === 'number';
477
}
478

479
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
480
  const yextDisplayCoordinate: Coordinate =(result.rawData as any)["yextDisplayCoordinate"];
20✔
481
  if (!yextDisplayCoordinate) {
16!
482
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
483
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
484
    return undefined;
1✔
485
  }
486
  if (!isCoordinate(yextDisplayCoordinate)) {
16!
487
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
488
    return undefined;
1✔
489
  }
490
  return yextDisplayCoordinate;
16✔
491
}
492

493
export function getMapboxLanguage(locale: string) {
6✔
494
  try {
22✔
495
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
496
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
497
  } catch (e) {
498
    console.warn(`Locale "${locale}" is not supported.`)
×
499
  }
500
  return 'en';
×
501
}
502

503
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
NEW
504
  const locationFilter = staticFilters.find(f => (f.filter as any)['fieldId'] === 'builtin.location' && (f.filter as any)['value'])?.filter;
×
505
  if (locationFilter) {
×
NEW
506
    const {lat, lng} = (locationFilter as any)['value'];
×
507
    return [lng, lat];
×
508
  }
509
}
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