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

yext / search-ui-react / 23448126896

23 Mar 2026 03:43PM UTC coverage: 84.786% (+0.2%) from 84.61%
23448126896

Pull #654

github

anguyen-yext2
replace Mapbox-owned public types in search-ui-react with library-owned, version-stable interfaces
Pull Request #654: chore: upgrade mapbox-gl version for accessibility issues (v3.0.0)

1066 of 1474 branches covered (72.32%)

Branch coverage included in aggregate %.

70 of 80 new or added lines in 3 files covered. (87.5%)

6 existing lines in 2 files now uncovered.

2328 of 2529 relevant lines covered (92.05%)

126.61 hits per line

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

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

7
import ReactDOM from 'react-dom';
7✔
8
// Try to statically import createRoot, will be undefined for <18.
9
import * as ReactDomClient from 'react-dom/client';
7✔
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;
11✔
22
const reactMajorVersion = Number(React.version.split('.')[0]);
11✔
23
const supportsCreateRoot = !Number.isNaN(reactMajorVersion) && reactMajorVersion >= 18;
11✔
24

25
/**
26
 * Coordinate use to represent the result's location on a map.
27
 *
28
 * @public
29
 */
30
export interface Coordinate {
31
  /** The latitude of the location. */
32
  latitude: number,
33
  /** The longitude of the location. */
34
  longitude: number
35
}
36

37
/**
38
 * A map center coordinate with helper methods that are owned by this library.
39
 *
40
 * @public
41
 */
42
export interface MapCenter extends Coordinate {
43
  /** Calculates the distance in meters between this coordinate and another coordinate. */
44
  distanceTo: (coordinate: Coordinate) => number
45
}
46

47
/**
48
 * A library-owned map bounds interface for drag and zoom callbacks.
49
 *
50
 * @public
51
 */
52
export interface MapBounds {
53
  /** Gets the north east corner of the current bounds. */
54
  getNorthEast: () => MapCenter,
55
  /** Gets the north west corner of the current bounds. */
56
  getNorthWest: () => MapCenter,
57
  /** Gets the south east corner of the current bounds. */
58
  getSouthEast: () => MapCenter,
59
  /** Gets the south west corner of the current bounds. */
60
  getSouthWest: () => MapCenter
61
}
62

63
/**
64
 * Padding around a fit-bounds request.
65
 *
66
 * @public
67
 */
68
export interface MapPadding {
69
  top?: number,
70
  bottom?: number,
71
  left?: number,
72
  right?: number
73
}
74

75
/**
76
 * Options used when fitting the map view to a set of bounds.
77
 *
78
 * @public
79
 */
80
export interface MapFitBoundsOptions {
81
  padding?: number | MapPadding,
82
  maxZoom?: number
83
}
84

85
/**
86
 * The subset of map configuration supported by this component.
87
 *
88
 * @public
89
 */
90
export interface MapboxMapOptions {
91
  center?: Coordinate,
92
  fitBoundsOptions?: MapFitBoundsOptions,
93
  maxZoom?: number,
94
  style?: string | Record<string, unknown>,
95
  zoom?: number
96
}
97

98
/**
99
 * Marker options supported by this component.
100
 *
101
 * @public
102
 */
103
export interface MapMarkerOptions {
104
  anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
105
  className?: string,
106
  clickTolerance?: number,
107
  color?: string,
108
  draggable?: boolean,
109
  occludedOpacity?: number,
110
  pitchAlignment?: 'map' | 'viewport' | 'auto',
111
  rotation?: number,
112
  rotationAlignment?: 'map' | 'viewport' | 'auto' | 'horizon',
113
  scale?: number
114
}
115

116
/**
117
 * A library-owned facade over the backing map implementation.
118
 *
119
 * @public
120
 */
121
export interface MapInstance {
122
  fitBounds: (bounds: MapBounds, options?: MapFitBoundsOptions) => void,
123
  flyTo: (options: { center: Coordinate }) => void,
124
  getBounds: () => MapBounds | undefined,
125
  getCenter: () => MapCenter,
126
  /**
127
   * Returns the native map implementation instance for advanced integrations.
128
   *
129
   * @remarks
130
   * This value is intentionally untyped because it is implementation-specific.
131
   */
132
  getNativeInstance: () => unknown,
133
  resize: () => void
134
}
135

136
/**
137
 * Props for rendering a custom marker on the map.
138
 *
139
 * @public
140
 */
141
export type PinComponentProps<T> = {
142
  /** The index of the pin. */
143
  index: number,
144
  /** A stable map facade for advanced pin interactions. */
145
  mapbox: MapInstance,
146
  /** The search result corresponding to the pin. */
147
  result: Result<T>,
148
  /** Whether the pin is selected. */
149
  selected?: boolean
150
};
151

152
/**
153
 * A functional component that can be used to render a custom marker on the map.
154
 *
155
 * @public
156
 */
157
export type PinComponent<T> = (props: PinComponentProps<T>) => React.JSX.Element;
158

159
/**
160
 * A function use to derive a result's coordinate.
161
 *
162
 * @public
163
 */
164
export type CoordinateGetter<T> = (result: Result<T>) => Coordinate | undefined;
165

166
/**
167
 * A function which is called when user drags or zooms the map.
168
 *
169
 * @public
170
 */
171
export type OnDragHandler = (center: MapCenter, bounds: MapBounds) => void;
172

173
/**
174
 * Props for the {@link MapboxMap} component.
175
 * The type param "T" represents the type of "rawData" field of the results use in the map.
176
 *
177
 * @public
178
 */
179
export interface MapboxMapProps<T> {
180
  /** Mapbox access token. */
181
  mapboxAccessToken: string,
182
  /** Interface for map customization supported by this component. */
183
  mapboxOptions?: MapboxMapOptions,
184
  /**
185
   * Custom Pin component to render for markers on the map.
186
   * By default, the built-in marker image from Mapbox GL is used.
187
   * This prop should not be used with
188
   * {@link MapboxMapProps.renderPin | renderPin}. If both are provided,
189
   * only PinComponent will be used.
190
   */
191
  PinComponent?: PinComponent<T>,
192
  /**
193
   * Render function for a custom marker on the map. This function takes in an
194
   * HTML element and is responible for rendering the pin into that element,
195
   * which will be used as the marker.
196
   * By default, the built-in marker image from Mapbox GL is used.
197
   * This prop should not be used with
198
   * {@link MapboxMapProps.PinComponent | PinComponent}. If both are provided,
199
   * only PinComponent will be used.
200
   */
201
  renderPin?: (props: PinComponentProps<T> & { container: HTMLElement }) => void,
202
  /**
203
   * A function to derive a result's coordinate for the corresponding marker's location on the map.
204
   * By default, "yextDisplayCoordinate" field is used as the result's display coordinate.
205
   */
206
  getCoordinate?: CoordinateGetter<T>,
207
  /** {@inheritDoc OnDragHandler} */
208
  onDrag?: OnDragHandler,
209
  /**
210
   * The window object of the iframe where the map should rendered. Must have mapboxgl loaded.
211
   * If not provided or mapboxgl not loaded, the map will be rendered in the parent window.
212
   */
213
  iframeWindow?: Window,
214
  /**
215
   * If set to true, the map will update its options when the mapboxOptions prop changes.
216
   * Otherwise, the map will not update its options once initially set.
217
   */
218
  allowUpdates?: boolean,
219
  /** A function that handles a pin click event. */
220
  onPinClick?: (result: Result<T> | undefined) => void,
221
  /** The options to apply to the map markers based on whether it is selected. */
222
  markerOptionsOverride?: (selected: boolean) => MapMarkerOptions
223
}
224

225
/**
226
 * A component that renders a map with markers to show result locations using Mapbox GL.
227
 *
228
 * @remarks
229
 * For the map to work properly, be sure to include Mapbox GL stylesheet in the application.
230
 *
231
 * @example
232
 * For instance, user may add the following import statement in their application's index file
233
 * or in the file where `MapboxMap` is used:
234
 * `import 'mapbox-gl/dist/mapbox-gl.css';`
235
 *
236
 * @param props - {@link MapboxMapProps}
237
 * @returns A React element containing a Mapbox Map
238
 *
239
 * @public
240
 */
241
export function MapboxMap<T>({
7✔
242
  mapboxAccessToken,
243
  mapboxOptions,
244
  PinComponent,
245
  renderPin,
246
  getCoordinate = getDefaultCoordinate,
23✔
247
  onDrag,
248
  iframeWindow,
249
  allowUpdates = false,
22✔
250
  onPinClick,
251
  markerOptionsOverride,
252
}: MapboxMapProps<T>): React.JSX.Element {
253
  const mapboxInstance = (iframeWindow as Window & { mapboxgl?: typeof mapboxgl })?.mapboxgl ?? mapboxgl;
24✔
254
  // keep the mapbox access token in sync with prop changes.
255
  useEffect(() => {
13✔
256
    mapboxInstance.accessToken = mapboxAccessToken;
10✔
257
  }, [mapboxAccessToken, mapboxInstance]);
258

259
  const mapContainer = useRef<HTMLDivElement>(null);
24✔
260
  const map = useRef<mapboxgl.Map | null>(null);
24✔
261
  const markers = useRef<mapboxgl.Marker[]>([]);
24✔
262
  const mapFacade = useRef<MapInstance | null>(null);
24✔
263
  const markerRoots = useRef(new Map<HTMLElement, RootHandle>());
24✔
264
  const activeMarkerElements = useRef(new Set<HTMLElement>());
24✔
265
  const markerData = useRef<Array<{ marker: mapboxgl.Marker, result: Result<T>, index: number }>>([]);
24✔
266

267
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
24✔
268
  const staticFilters = useSearchState(state => state.filters?.static);
24✔
269
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
24✔
270
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
24✔
271

272
  const handlePinClick = useCallback((result: Result<T>) => {
24✔
273
    setSelectedResult(prev => prev === result ? undefined : result);
13!
274
  }, []);
275

276
  // notify consumers when the selected pin changes.
277
  useEffect(() => {
13✔
278
    onPinClick?.(selectedResult);
13✔
279
  }, [onPinClick, selectedResult]);
280

281
  const scheduleRootUnmount = useCallback((root: RootHandle) => {
24✔
282
    if (typeof queueMicrotask === 'function') {
15!
283
      queueMicrotask(() => root.unmount());
15✔
284
    } else {
285
      setTimeout(() => root.unmount(), 0);
13✔
286
    }
287
  }, []);
288

289
  const cleanupPinComponent = useCallback((element: HTMLElement) => {
24✔
290
    activeMarkerElements.current.delete(element);
22✔
291
    if (supportsCreateRoot) {
22!
292
      const root = markerRoots.current.get(element);
19✔
293
      if (root) {
22✔
294
        // unmount must be called after the current render finishes, so schedule it for the next
295
        // microtask
296
        scheduleRootUnmount(root);
15✔
297
        markerRoots.current.delete(element);
15✔
298
      }
299
    } else {
300
      legacyReactDOM.unmountComponentAtNode?.(element);
13✔
301
    }
302
  }, [scheduleRootUnmount]);
303

304
  const attachPinComponent = useCallback((element: HTMLElement, component: React.JSX.Element) => {
24✔
305
    if (supportsCreateRoot && typeof ReactDomClient.createRoot === 'function') {
17!
306
      // Use React 18+ API
307
      let root = markerRoots.current.get(element);
10✔
308
      if (!root) {
17✔
309
        root = ReactDomClient.createRoot(element);
17✔
310
        markerRoots.current.set(element, root);
17✔
311
      }
312
      root.render(component);
17✔
313
    } else if (typeof legacyReactDOM.render === 'function') {
13!
314
      // Fallback for React <18
315
      legacyReactDOM.render(component, element);
13✔
316
    }
317
  }, []);
318

319
  // builds and attaches a single marker to the mapbox map
320
  const createMarker = useCallback((
24✔
321
    mapbox: MapInstance,
322
    result: Result<T>,
323
    index: number,
324
    selected: boolean
325
  ) => {
326
    const markerLocation = getCoordinate(result);
27✔
327
    if (!markerLocation) {
24!
328
      return null;
15✔
329
    }
330
    const { latitude, longitude } = markerLocation;
25✔
331
    if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
22!
332
      return null;
13✔
333
    }
334

335
    const el = document.createElement('div');
25✔
336
    let markerOptions: mapboxgl.MarkerOptions = {};
25✔
337
    if (PinComponent) {
22✔
338
      if (renderPin) {
15!
339
        console.warn(
14✔
340
          'Found both PinComponent and renderPin props. Using PinComponent.'
341
        );
342
      }
343
      attachPinComponent(el, (
15✔
344
        <PinComponent
345
          index={index}
346
          mapbox={mapbox}
347
          result={result}
348
          selected={selected}
349
        />
350
      ));
351
      markerOptions.element = el;
15✔
352
    } else if (renderPin) {
20!
353
      renderPin({ index, mapbox, result, container: el });
13✔
354
      markerOptions.element = el;
13✔
355
    }
356

357
    if (markerOptionsOverride) {
22!
358
      markerOptions = {
13✔
359
        ...markerOptions,
360
        ...toNativeMarkerOptions(markerOptionsOverride(selected))
361
      };
362
    }
363

364
    const nativeMap = mapbox.getNativeInstance();
25✔
365
    if (!(nativeMap instanceof mapboxgl.Map)) {
22!
366
      return null;
13✔
367
    }
368

369
    const marker = new mapboxInstance.Marker(markerOptions)
25✔
370
      .setLngLat({ lat: latitude, lng: longitude })
371
      .addTo(nativeMap);
372

373
    marker?.getElement().addEventListener('click', () => handlePinClick(result));
22✔
374

375
    return { marker, location: markerLocation };
22✔
376
  }, [
377
    PinComponent,
378
    attachPinComponent,
379
    getCoordinate,
380
    handlePinClick,
381
    mapboxInstance,
382
    markerOptionsOverride,
383
    renderPin
384
  ]);
385

386
  const removeMarkers = useCallback(() => {
24✔
387
    markers.current.forEach(marker => {
35✔
388
      if (!marker) {
22!
389
        return;
13✔
390
      }
391
      const element = marker?.getElement?.();
19✔
392
      if (element) {
22✔
393
        cleanupPinComponent(element);
22✔
394
      }
395
      if (typeof marker.remove === 'function') {
22✔
396
        marker.remove();
22✔
397
      }
398
    });
399
    markers.current = [];
35✔
400
    markerData.current = [];
35✔
401
  }, [cleanupPinComponent]);
402

403
  const locale = useSearchState(state => state.meta?.locale);
24✔
404
  // keep track of the previous value of mapboxOptions across renders
405
  const prevMapboxOptions = useRef(mapboxOptions);
24✔
406

407
  /**
408
   * Localizes Mapbox label text to a specific locale.
409
   *
410
   * Updates symbol layers that are place names such that labels prefer `name_<lang>`
411
   * (e.g. `name_fr`) and fall back to `name` when unavailable.
412
   *
413
   * Note:
414
   * - Symbol layers that are place names would have `text-field` properties that includes
415
   *   'name', which are localized.
416
   * - Other symbol layers (e.g. road shields, transit, icons) are left unchanged.
417
   */
418
  const localizeMap = useCallback(() => {
24✔
419
    const mapbox = map.current;
21✔
420
    if (!mapbox || !locale) return;
24✔
421

422
    const localizeLabels = () => {
×
423
      mapbox.getStyle().layers.forEach(layer => {
×
424
        if (layer.type !== 'symbol') {
×
425
          return;
×
426
        }
427
        const textField = layer.layout?.['text-field'];
×
428
        if (typeof textField === 'string'
×
429
          ? textField.includes('name')
430
          : (Array.isArray(textField) && JSON.stringify(textField).includes('name'))) {
×
431
          mapbox.setLayoutProperty(
×
432
            layer.id,
433
            'text-field',
434
            [
435
              'coalesce',
436
              ['get', `name_${getMapboxLanguage(locale)}`],
437
              ['get', 'name']
438
            ]
439
          );
440
        }
441
      });
442
    };
443

444
    if (mapbox.isStyleLoaded()) {
13!
445
      localizeLabels();
13✔
446
    } else {
447
      mapbox.once('styledata', () => localizeLabels());
13✔
448
    }
449
  }, [locale]);
450

451
  // initialize the map once and update mapbox options when allowUpdates is true.
452
  useEffect(() => {
13✔
453
    if (mapContainer.current) {
11✔
454
      if (map.current && allowUpdates) {
11!
455
        // Compare current and previous mapboxOptions using deep equality
456
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
1!
457
          // Update to existing Map
458
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
1✔
459
          prevMapboxOptions.current = mapboxOptions;
1✔
460
        }
461
      } else if (!map.current && mapboxInstance) {
10✔
462
        const options: mapboxgl.MapOptions = {
20✔
463
          container: mapContainer.current,
464
          style: 'mapbox://styles/mapbox/streets-v11',
465
          center: [-74.005371, 40.741611],
466
          zoom: 9,
467
          ...toNativeMapboxOptions(mapboxOptions)
468
        };
469
        map.current = new mapboxInstance.Map(options);
10✔
470
        const nativeMap = map.current;
20✔
471
        mapFacade.current = createMapInstanceFacade(nativeMap);
10✔
472
        nativeMap.resize();
10✔
473
        const nav = new mapboxInstance.NavigationControl({
20✔
474
          showCompass: false,
475
          showZoom: true,
476
          visualizePitch: false
477
        });
478
        nativeMap.addControl(nav, 'top-right');
10✔
479
      }
480
      localizeMap();
11✔
481
    }
482
  }, [allowUpdates, localizeMap, mapboxInstance, mapboxOptions]);
483

484
  // Register drag listeners separately from map initialization so rerenders do not
485
  // accidentally remove them without reattaching them.
486
  useEffect(() => {
13✔
487
    const nativeMap = map.current;
20✔
488
    if (!nativeMap || !onDragDebounced) {
10!
489
      return;
9✔
490
    }
491

492
    const dispatchDrag = () => {
11✔
493
      const bounds = nativeMap.getBounds();
1✔
494
      if (!bounds) {
11!
495
        return;
10✔
496
      }
497
      onDragDebounced(toMapCenter(nativeMap.getCenter()), toMapBounds(bounds));
11✔
498
    };
499
    const onDrag = () => {
11✔
500
      dispatchDrag();
11✔
501
    };
502
    const onZoom = (e: mapboxgl.MapEventOf<'zoom'>) => {
11✔
503
      if ('originalEvent' in e && e.originalEvent) {
61!
504
        // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
505
        dispatchDrag();
10✔
506
      }
507
    };
508

509
    nativeMap.on('drag', onDrag);
10✔
510
    nativeMap.on('zoom', onZoom);
10✔
511

512
    return () => {
10✔
513
      nativeMap.off('drag', onDrag);
6✔
514
      nativeMap.off('zoom', onZoom);
6✔
515
    };
516
  }, [onDragDebounced]);
517

518
  // resize the map when its iframe container changes size.
519
  useEffect(() => {
13✔
520
    if (iframeWindow && map.current) {
10!
521
      map.current.resize();
×
522
    }
523
  }, [iframeWindow]);
524

525
  // create and place markers when results change, then cleanup on teardown
526
  useEffect(() => {
13✔
527
    removeMarkers();
11✔
528
    const nativeMap = map.current;
21✔
529
    const mapbox = mapFacade.current;
21✔
530
    if (nativeMap && mapbox && locationResults) {
11✔
531
      if (locationResults.length > 0) {
11!
532
        const bounds = new mapboxInstance.LngLatBounds();
21✔
533
        // create a marker for each result
534
        locationResults.forEach((result, i) => {
11✔
535
          const created = createMarker(mapbox, result, i, false);
27✔
536
          if (!created) {
16!
537
            return;
2✔
538
          }
539
          markers.current.push(created.marker);
16✔
540
          markerData.current.push({ marker: created.marker, result, index: i });
16✔
541
          bounds.extend([created.location.longitude, created.location.latitude]);
16✔
542
        });
543

544
        // fit the map to the markers
545
        nativeMap.resize();
11✔
546
        const canvas = nativeMap.getCanvas();
21✔
547

548
        // add padding to map
549
        if (!bounds.isEmpty()
11✔
550
            && !!canvas
551
            && canvas.clientHeight > 0
552
            && canvas.clientWidth > 0
553
        ) {
554
          const resolvedOptions = {
21✔
555
            // these settings are defaults and will be overriden if present on fitBoundsOptions
556
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
557
            maxZoom: mapboxOptions?.maxZoom ?? 15,
42✔
558
            ...toNativeFitBoundsOptions(mapboxOptions?.fitBoundsOptions),
559
          };
560

561
          let resolvedPadding;
562
          if (typeof resolvedOptions.padding === 'number') {
11!
563
            resolvedPadding = {
×
564
              top: resolvedOptions.padding,
565
              bottom: resolvedOptions.padding,
566
              left: resolvedOptions.padding,
567
              right: resolvedOptions.padding
568
            };
569
          } else {
570
            resolvedPadding = {
11✔
571
              top: resolvedOptions.padding?.top ?? 0,
21!
572
              bottom: resolvedOptions.padding?.bottom ?? 0,
21!
573
              left: resolvedOptions.padding?.left ?? 0,
21!
574
              right: resolvedOptions.padding?.right ?? 0
21!
575
            };
576
          }
577

578
          // Padding must not exceed the map's canvas dimensions
579
          const verticalPaddingSum = resolvedPadding.top + resolvedPadding.bottom;
21✔
580
          if (verticalPaddingSum >= canvas.clientHeight) {
11!
581
            const ratio = canvas.clientHeight / (verticalPaddingSum || 1);
×
582
            resolvedPadding.top = Math.max(0, resolvedPadding.top * ratio - 1);
×
583
            resolvedPadding.bottom = Math.max(0, resolvedPadding.bottom * ratio - 1);
×
584
          }
585
          const horizontalPaddingSum = resolvedPadding.left + resolvedPadding.right;
21✔
586
          if (horizontalPaddingSum >= canvas.clientWidth) {
11!
587
            const ratio = canvas.clientWidth / (horizontalPaddingSum || 1);
×
588
            resolvedPadding.left = Math.max(0, resolvedPadding.left * ratio - 1);
×
589
            resolvedPadding.right = Math.max(0, resolvedPadding.right * ratio - 1);
×
590
          }
591
          resolvedOptions.padding = resolvedPadding;
11✔
592
          nativeMap.fitBounds(bounds, resolvedOptions);
11✔
593
        }
594

595
        // return a cleanup function to remove markers when the map component unmounts
596
        return () => {
11✔
597
          markers.current.forEach((marker, i) => {
11✔
598
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
10✔
599
          });
600
          removeMarkers();
11✔
601
        };
602
      } else if (staticFilters?.length) {
×
603
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
604
        if (locationFilterValue) {
×
NEW
605
          nativeMap.flyTo({
×
606
            center: locationFilterValue
607
          });
608
        }
609
      }
610
    }
611
  }, [
612
    createMarker,
613
    handlePinClick,
614
    locationResults,
615
    mapboxInstance,
616
    mapboxOptions,
617
    removeMarkers,
618
    staticFilters
619
  ]);
620

621
  const previousSelectedResult = useRef<Result<T> | undefined>(undefined);
24✔
622

623
  // update marker options when markerOptionsOverride changes or selectedResult changes
624
  useEffect(() => {
13✔
625
    const mapbox = mapFacade.current;
23✔
626
    if (!mapbox || !markerOptionsOverride) {
13✔
627
      previousSelectedResult.current = selectedResult;
13✔
628
      return;
13✔
629
    }
630

631
    const prevSelected = previousSelectedResult.current;
×
632
    previousSelectedResult.current = selectedResult;
×
633

634
    // markerOptionsOverride is applied at creation time, so we recreate only the affected
635
    // markers to reflect selection changes without tearing down all pins.
636
    const resultsToUpdate = new Set<Result<T>>();
×
637
    if (prevSelected) {
×
638
      resultsToUpdate.add(prevSelected);
×
639
    }
640
    if (selectedResult) {
×
641
      resultsToUpdate.add(selectedResult);
×
642
    }
643

644
    resultsToUpdate.forEach((result) => {
×
645
      const markerEntry = markerData.current.find(entry => entry.result === result);
×
646
      if (!markerEntry) {
×
647
        return;
×
648
      }
649
      // recreate the marker to apply new markerOptionsOverride (e.g. color/scale).
650
      const oldMarker = markerEntry.marker;
×
651
      const element = oldMarker?.getElement?.();
×
652
      if (element) {
×
653
        cleanupPinComponent(element);
×
654
      }
655
      oldMarker?.remove?.();
×
656

657
      const created = createMarker(mapbox, result, markerEntry.index, selectedResult === result);
×
658
      if (!created) {
×
659
        return;
×
660
      }
661
      markerEntry.marker = created.marker;
×
662
      markers.current[markerEntry.index] = created.marker;
×
663
    });
664
  }, [cleanupPinComponent, createMarker, markerOptionsOverride, selectedResult]);
665

666
  // re-render custom PinComponent on selection changes to update the visual state
667
  useEffect(() => {
13✔
668
    const mapbox = mapFacade.current;
23✔
669
    if (!mapbox || !PinComponent) {
13✔
670
      return;
9✔
671
    }
672
    markerData.current.forEach(({ marker, result, index }) => {
4✔
673
      const element = marker?.getElement?.();
6✔
674
      if (!element) {
4!
UNCOV
675
        return;
×
676
      }
677
      attachPinComponent(element, (
4✔
678
        <PinComponent
679
          index={index}
680
          mapbox={mapbox}
681
          result={result}
682
          selected={selectedResult === result}
683
        />
684
      ));
685
    });
686
  }, [attachPinComponent, PinComponent, selectedResult]);
687

688
  return (
13✔
689
    <div ref={mapContainer} className='h-full w-full' />
690
  );
691
}
692

693
function toMapCenter(lngLat: mapboxgl.LngLat): MapCenter {
694
  const coordinate = {
4✔
695
    latitude: lngLat.lat,
696
    longitude: lngLat.lng
697
  };
698

699
  return {
4✔
700
    ...coordinate,
701
    distanceTo: (nextCoordinate: Coordinate) => lngLat.distanceTo(
1✔
702
      new mapboxgl.LngLat(nextCoordinate.longitude, nextCoordinate.latitude)
703
    )
704
  };
705
}
706

707
function toMapBounds(bounds: mapboxgl.LngLatBounds): MapBounds {
708
  return {
1✔
709
    getNorthEast: () => toMapCenter(bounds.getNorthEast()),
2✔
NEW
710
    getNorthWest: () => toMapCenter(bounds.getNorthWest()),
×
NEW
711
    getSouthEast: () => toMapCenter(bounds.getSouthEast()),
×
NEW
712
    getSouthWest: () => toMapCenter(bounds.getSouthWest())
×
713
  };
714
}
715

716
function toNativeCoordinate(coordinate: Coordinate): [number, number] {
717
  return [coordinate.longitude, coordinate.latitude];
1✔
718
}
719

720
function toNativeFitBoundsOptions(
721
  fitBoundsOptions: MapFitBoundsOptions | undefined
722
): mapboxgl.MapOptions['fitBoundsOptions'] | undefined {
723
  if (!fitBoundsOptions) {
14✔
724
    return undefined;
12✔
725
  }
726

727
  return {
2✔
728
    ...fitBoundsOptions,
729
    padding: fitBoundsOptions.padding
2!
730
      ? typeof fitBoundsOptions.padding === 'number'
2!
731
        ? fitBoundsOptions.padding
732
        : {
733
          top: fitBoundsOptions.padding.top ?? 0,
2!
734
          bottom: fitBoundsOptions.padding.bottom ?? 0,
2!
735
          left: fitBoundsOptions.padding.left ?? 0,
2!
736
          right: fitBoundsOptions.padding.right ?? 0
2!
737
        }
738
      : undefined
739
  };
740
}
741

742
function toNativeMapboxOptions(mapboxOptions: MapboxMapOptions | undefined): Omit<mapboxgl.MapOptions, 'container'> {
743
  if (!mapboxOptions) {
10✔
744
    return {};
10✔
745
  }
746

747
  return {
3✔
748
    ...mapboxOptions,
749
    center: mapboxOptions.center ? toNativeCoordinate(mapboxOptions.center) : undefined,
3✔
750
    fitBoundsOptions: toNativeFitBoundsOptions(mapboxOptions.fitBoundsOptions),
751
    style: mapboxOptions.style as mapboxgl.StyleSpecification | string | undefined
752
  };
753
}
754

755
function toNativeMarkerOptions(markerOptions: MapMarkerOptions): mapboxgl.MarkerOptions {
NEW
756
  return { ...markerOptions };
×
757
}
758

759
function createMapInstanceFacade(map: mapboxgl.Map): MapInstance {
760
  return {
10✔
761
    fitBounds: (bounds, options) => {
NEW
762
      map.fitBounds(
×
763
        [
764
          toNativeCoordinate(bounds.getSouthWest()),
765
          toNativeCoordinate(bounds.getNorthEast())
766
        ],
767
        toNativeFitBoundsOptions(options)
768
      );
769
    },
770
    flyTo: ({ center }) => {
NEW
771
      map.flyTo({ center: toNativeCoordinate(center) });
×
772
    },
773
    getBounds: () => {
NEW
774
      const bounds = map.getBounds();
×
NEW
775
      return bounds ? toMapBounds(bounds) : undefined;
×
776
    },
777
    getCenter: () => toMapCenter(map.getCenter()),
1✔
778
    getNativeInstance: () => map,
28✔
779
    resize: () => {
NEW
780
      map.resize();
×
781
    }
782
  };
783
}
784

785
function handleMapboxOptionsUpdates(mapboxOptions: MapboxMapOptions | undefined, currentMap: mapboxgl.Map) {
786
  if (mapboxOptions?.style) {
1!
787
    currentMap.setStyle(mapboxOptions.style as mapboxgl.StyleSpecification | string);
1✔
788
  }
789
  // Add more options to update as needed
790
}
791

792
function isCoordinate(data: unknown): data is Coordinate {
793
  return typeof data == 'object'
16✔
794
    && typeof (data as any)?.['latitude'] === 'number'
795
    && typeof (data as any)?.['longitude'] === 'number';
796
}
797

798
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
799
  const yextDisplayCoordinate: Coordinate = (result.rawData as any)['yextDisplayCoordinate'];
26✔
800
  if (!yextDisplayCoordinate) {
16!
801
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
802
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
803
    return undefined;
1✔
804
  }
805
  if (!isCoordinate(yextDisplayCoordinate)) {
16!
806
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
807
    return undefined;
1✔
808
  }
809
  return yextDisplayCoordinate;
16✔
810
}
811

812
export function getMapboxLanguage(locale: string) {
7✔
813
  try {
22✔
814
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
815
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
816
  } catch (e) {
817
    console.warn(`Locale "${locale}" is not supported.`);
×
818
  }
819
  return 'en';
×
820
}
821

822
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
823
  const locationFilter = staticFilters.find(f => (f.filter as any)['fieldId'] === 'builtin.location' && (f.filter as any)['value'])?.filter;
×
824
  if (locationFilter) {
×
825
    const { lat, lng } = (locationFilter as any)['value'];
×
826
    return [lng, lat];
×
827
  }
828
}
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