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

yext / search-ui-react / 23466030698

23 Mar 2026 11:54PM UTC coverage: 84.783% (+0.2%) from 84.61%
23466030698

Pull #654

github

github-actions[bot]
Automated update to repo's documentation from github action
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%)

1 existing line in 1 file now uncovered.

2327 of 2528 relevant lines covered (92.05%)

126.8 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
  element?: HTMLElement,
105
  offset?: [number, number],
106
  anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
107
  color?: string,
108
  scale?: number,
109
  draggable?: boolean,
110
  clickTolerance?: number,
111
  rotation?: number,
112
  rotationAlignment?: 'map' | 'viewport' | 'auto' | 'horizon',
113
  pitchAlignment?: 'map' | 'viewport' | 'auto',
114
  occludedOpacity?: number,
115
  className?: string,
116
  altitude?: number
117
}
118

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

360
    if (markerOptionsOverride) {
22!
361
      markerOptions = {
13✔
362
        ...markerOptions,
363
        ...toNativeMarkerOptions(markerOptionsOverride(selected))
364
      };
365
    }
366

367
    const nativeMap = mapbox.getNativeInstance();
25✔
368
    if (!(nativeMap instanceof mapboxInstance.Map)) {
22!
369
      return null;
13✔
370
    }
371

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

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

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

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

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

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

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

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

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

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

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

512
    nativeMap.on('drag', onDrag);
10✔
513
    nativeMap.on('zoom', onZoom);
10✔
514

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

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

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

547
        // fit the map to the markers
548
        nativeMap.resize();
11✔
549
        const canvas = nativeMap.getCanvas();
21✔
550

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

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

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

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

624
  const previousSelectedResult = useRef<Result<T> | undefined>(undefined);
24✔
625

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

634
    const prevSelected = previousSelectedResult.current;
×
635
    previousSelectedResult.current = selectedResult;
×
636

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

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

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

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

691
  return (
13✔
692
    <div ref={mapContainer} className='h-full w-full' />
693
  );
694
}
695

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

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

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

719
function toNativeCoordinate(coordinate: Coordinate): [number, number] {
720
  return [coordinate.longitude, coordinate.latitude];
1✔
721
}
722

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

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

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

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

758
function toNativeMarkerOptions(markerOptions: MapMarkerOptions): mapboxgl.MarkerOptions {
NEW
759
  return { ...markerOptions };
×
760
}
761

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

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

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

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

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

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