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

yext / search-ui-react / 24469789513

15 Apr 2026 05:54PM UTC coverage: 84.463% (-0.1%) from 84.61%
24469789513

push

github

web-flow
Merge main (v3.0.1) into develop (#661)

* feat: allow users to turn analytics on and off (v2.1.0) (#632)

* feat: allow users to turn analytics on and off (v2.1.0)

This PR adds a new property to the SearchAnalyticsConfig, which
can be used to start with analytics enabled or disabled by
default. Three new methods are added to the analytics object-
two to turn analytics on/off, and one to get the current enabled
status.

J=WAT-5404
TEST=auto, manual

Ran test site locally with debugging and buttons to turn
analytics on and off, saw expected behavior.

* Update various names to be in-line with pages code

* Automated update to repo's documentation from github action

* Add enableYextAnalytics function to window

* drop unneeded parens

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>

* release: v2.1.0

* More accessbilities fixes for autocomplete results (#635)

* More accessbilities fixes for autocomplete results

Associate the visible label with the input via `label` + `htmlFor`, and wire `DropdownInput` with `inputId` and `aria-labelledby`.

Change the instructions container from `hidden` to `sr-only`, so `aria-describedby` references content that is actually exposed to screen readers.

J=WAT-5357
TEST=manual

tested with voice over enabled on test-site

* Update snapshots

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>

* release: v2.1.1

* chore: suppress error/warning spam, fix key errors etc (#642)

* fix: point TypeScript output to dist directory (#644)

* suppress error/warning spam, fix key errors etc

* set ts output dir to dist

* retry logic to WCAG

* release: v2.1.2

* fix: improve accessibility in FilterSearch and Facets (v2.2.0) (#638)

* fix linting; added optional region landmark semantics to FilterSearch

* Automated update to repo's documentation from github action

* chore: suppress error/warning spam, fix key errors etc (#642)

* ... (continued)

1070 of 1483 branches covered (72.15%)

Branch coverage included in aggregate %.

91 of 115 new or added lines in 7 files covered. (79.13%)

1 existing line in 1 file now uncovered.

2344 of 2559 relevant lines covered (91.6%)

124.32 hits per line

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

66.14
/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
const KEYBOARD_MOVE_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
11✔
25

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

38
/**
39
 * A map center coordinate with helper methods that are owned by this library.
40
 *
41
 * @public
42
 */
43
export interface MapCenter extends Coordinate {
44
  /** Returns a new coordinate whose longitude is wrapped to the range (-180, 180). */
45
  wrap: () => MapCenter,
46
  /** Returns the coordinate as a longitude-latitude tuple. */
47
  toArray: () => [number, number],
48
  /** Returns a string representation of the coordinate. */
49
  toString: () => string,
50
  /** Calculates the distance in meters between this coordinate and another coordinate. */
51
  distanceTo: (coordinate: Coordinate) => number,
52
  /** Returns bounds expanded by the provided radius in meters. */
53
  toBounds: (radius?: number) => MapBounds,
54
  /** Converts this coordinate to Earth-Centered, Earth-Fixed coordinates. */
55
  toEcef: (altitude: number) => [number, number, number]
56
}
57

58
/**
59
 * A library-owned map bounds interface for drag and zoom callbacks.
60
 *
61
 * @public
62
 */
63
export interface MapBounds {
64
  /** Sets the north east corner of the bounds. */
65
  setNorthEast: (coordinate: Coordinate) => MapBounds,
66
  /** Sets the south west corner of the bounds. */
67
  setSouthWest: (coordinate: Coordinate) => MapBounds,
68
  /** Extends the bounds to include the provided coordinate or bounds. */
69
  extend: (coordinateOrBounds: Coordinate | MapBounds) => MapBounds,
70
  /** Gets the center of the current bounds. */
71
  getCenter: () => MapCenter,
72
  /** Gets the south west corner of the current bounds. */
73
  getSouthWest: () => MapCenter,
74
  /** Gets the north east corner of the current bounds. */
75
  getNorthEast: () => MapCenter,
76
  /** Gets the north west corner of the current bounds. */
77
  getNorthWest: () => MapCenter,
78
  /** Gets the south east corner of the current bounds. */
79
  getSouthEast: () => MapCenter,
80
  /** Gets the west edge of the current bounds. */
81
  getWest: () => number,
82
  /** Gets the south edge of the current bounds. */
83
  getSouth: () => number,
84
  /** Gets the east edge of the current bounds. */
85
  getEast: () => number,
86
  /** Gets the north edge of the current bounds. */
87
  getNorth: () => number,
88
  /** Returns the bounds as southwest and northeast longitude-latitude tuples. */
89
  toArray: () => [[number, number], [number, number]],
90
  /** Returns a string representation of the bounds. */
91
  toString: () => string,
92
  /** Returns whether the bounds are empty. */
93
  isEmpty: () => boolean,
94
  /** Returns whether the provided coordinate is contained within the bounds. */
95
  contains: (coordinate: Coordinate) => boolean
96
}
97

98
/**
99
 * Padding around a fit-bounds request.
100
 *
101
 * @public
102
 */
103
export interface MapPadding {
104
  top?: number,
105
  bottom?: number,
106
  left?: number,
107
  right?: number
108
}
109

110
/**
111
 * Options used when fitting the map view to a set of bounds.
112
 *
113
 * @public
114
 */
115
export interface MapFitBoundsOptions {
116
  padding?: number | MapPadding,
117
  maxZoom?: number
118
}
119

120
/**
121
 * The subset of map configuration supported by this component.
122
 *
123
 * @public
124
 */
125
export interface MapboxMapOptions {
126
  center?: Coordinate,
127
  fitBoundsOptions?: MapFitBoundsOptions,
128
  maxZoom?: number,
129
  style?: string | Record<string, unknown>,
130
  zoom?: number
131
}
132

133
/**
134
 * Marker options supported by this component.
135
 *
136
 * @public
137
 */
138
export interface MapMarkerOptions {
139
  element?: HTMLElement,
140
  offset?: [number, number],
141
  anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
142
  color?: string,
143
  scale?: number,
144
  draggable?: boolean,
145
  clickTolerance?: number,
146
  rotation?: number,
147
  rotationAlignment?: 'map' | 'viewport' | 'auto' | 'horizon',
148
  pitchAlignment?: 'map' | 'viewport' | 'auto',
149
  occludedOpacity?: number,
150
  className?: string,
151
  altitude?: number
152
}
153

154
/**
155
 * A library-owned facade over the backing map implementation.
156
 *
157
 * @public
158
 */
159
export interface MapInstance {
160
  fitBounds: (bounds: MapBounds, options?: MapFitBoundsOptions) => void,
161
  flyTo: (options: { center: Coordinate }) => void,
162
  getBounds: () => MapBounds | undefined,
163
  getCenter: () => MapCenter,
164
  /**
165
   * Returns the native map implementation instance for advanced integrations.
166
   *
167
   * @remarks
168
   * This value is intentionally untyped because it is implementation-specific.
169
   */
170
  getNativeInstance: () => unknown,
171
  resize: () => void
172
}
173

174
/**
175
 * Props for rendering a custom marker on the map.
176
 *
177
 * @public
178
 */
179
export type PinComponentProps<T> = {
180
  /** The index of the pin. */
181
  index: number,
182
  /** A stable map facade for advanced pin interactions. */
183
  mapbox: MapInstance,
184
  /** The search result corresponding to the pin. */
185
  result: Result<T>,
186
  /** Whether the pin is selected. */
187
  selected?: boolean
188
};
189

190
/**
191
 * A functional component that can be used to render a custom marker on the map.
192
 *
193
 * @public
194
 */
195
export type PinComponent<T> = (props: PinComponentProps<T>) => React.JSX.Element;
196

197
/**
198
 * A function use to derive a result's coordinate.
199
 *
200
 * @public
201
 */
202
export type CoordinateGetter<T> = (result: Result<T>) => Coordinate | undefined;
203

204
/**
205
 * A function which is called when the user changes the map viewport.
206
 *
207
 * @public
208
 */
209
export type OnDragHandler = (center: MapCenter, bounds: MapBounds) => void;
210

211
/**
212
 * Props for the {@link MapboxMap} component.
213
 * The type param "T" represents the type of "rawData" field of the results use in the map.
214
 *
215
 * @public
216
 */
217
export interface MapboxMapProps<T> {
218
  /** Mapbox access token. */
219
  mapboxAccessToken: string,
220
  /** Interface for map customization supported by this component. */
221
  mapboxOptions?: MapboxMapOptions,
222
  /**
223
   * Custom Pin component to render for markers on the map.
224
   * By default, the built-in marker image from Mapbox GL is used.
225
   * This prop should not be used with
226
   * {@link MapboxMapProps.renderPin | renderPin}. If both are provided,
227
   * only PinComponent will be used.
228
   */
229
  PinComponent?: PinComponent<T>,
230
  /**
231
   * Render function for a custom marker on the map. This function takes in an
232
   * HTML element and is responible for rendering the pin into that element,
233
   * which will be used as the marker.
234
   * By default, the built-in marker image from Mapbox GL is used.
235
   * This prop should not be used with
236
   * {@link MapboxMapProps.PinComponent | PinComponent}. If both are provided,
237
   * only PinComponent will be used.
238
   */
239
  renderPin?: (props: PinComponentProps<T> & { container: HTMLElement }) => void,
240
  /**
241
   * A function to derive a result's coordinate for the corresponding marker's location on the map.
242
   * By default, "yextDisplayCoordinate" field is used as the result's display coordinate.
243
   */
244
  getCoordinate?: CoordinateGetter<T>,
245
  /** {@inheritDoc OnDragHandler} */
246
  onDrag?: OnDragHandler,
247
  /**
248
   * The window object of the iframe where the map should rendered. Must have mapboxgl loaded.
249
   * If not provided or mapboxgl not loaded, the map will be rendered in the parent window.
250
   */
251
  iframeWindow?: Window,
252
  /**
253
   * If set to true, the map will update its options when the mapboxOptions prop changes.
254
   * Otherwise, the map will not update its options once initially set.
255
   */
256
  allowUpdates?: boolean,
257
  /** A function that handles a pin click event. */
258
  onPinClick?: (result: Result<T> | undefined) => void,
259
  /** The options to apply to the map markers based on whether it is selected. */
260
  markerOptionsOverride?: (selected: boolean) => MapMarkerOptions
261
}
262

263
/**
264
 * A component that renders a map with markers to show result locations using Mapbox GL.
265
 *
266
 * @remarks
267
 * For the map to work properly, be sure to include Mapbox GL stylesheet in the application.
268
 *
269
 * @example
270
 * For instance, user may add the following import statement in their application's index file
271
 * or in the file where `MapboxMap` is used:
272
 * `import 'mapbox-gl/dist/mapbox-gl.css';`
273
 *
274
 * @param props - {@link MapboxMapProps}
275
 * @returns A React element containing a Mapbox Map
276
 *
277
 * @public
278
 */
279
export function MapboxMap<T>({
7✔
280
  mapboxAccessToken,
281
  mapboxOptions,
282
  PinComponent,
283
  renderPin,
284
  getCoordinate = getDefaultCoordinate,
23✔
285
  onDrag,
286
  iframeWindow,
287
  allowUpdates = false,
22✔
288
  onPinClick,
289
  markerOptionsOverride,
290
}: MapboxMapProps<T>): React.JSX.Element {
291
  const mapboxInstance = (iframeWindow as Window & { mapboxgl?: typeof mapboxgl })?.mapboxgl ?? mapboxgl;
24✔
292
  // keep the mapbox access token in sync with prop changes.
293
  useEffect(() => {
13✔
294
    mapboxInstance.accessToken = mapboxAccessToken;
10✔
295
  }, [mapboxAccessToken, mapboxInstance]);
296

297
  const mapContainer = useRef<HTMLDivElement>(null);
24✔
298
  const map = useRef<mapboxgl.Map | null>(null);
24✔
299
  const markers = useRef<mapboxgl.Marker[]>([]);
24✔
300
  const mapFacade = useRef<MapInstance | null>(null);
24✔
301
  const markerRoots = useRef(new Map<HTMLElement, RootHandle>());
24✔
302
  const activeMarkerElements = useRef(new Set<HTMLElement>());
24✔
303
  const markerData = useRef<Array<{ marker: mapboxgl.Marker, result: Result<T>, index: number }>>([]);
24✔
304

305
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
24✔
306
  const staticFilters = useSearchState(state => state.filters?.static);
24✔
307
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
24✔
308
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
24✔
309

310
  const handlePinClick = useCallback((result: Result<T>) => {
24✔
311
    setSelectedResult(prev => prev === result ? undefined : result);
13!
312
  }, []);
313

314
  // notify consumers when the selected pin changes.
315
  useEffect(() => {
13✔
316
    onPinClick?.(selectedResult);
13✔
317
  }, [onPinClick, selectedResult]);
318

319
  const scheduleRootUnmount = useCallback((root: RootHandle) => {
24✔
320
    if (typeof queueMicrotask === 'function') {
15!
321
      queueMicrotask(() => root.unmount());
15✔
322
    } else {
323
      setTimeout(() => root.unmount(), 0);
13✔
324
    }
325
  }, []);
326

327
  const cleanupPinComponent = useCallback((element: HTMLElement) => {
24✔
328
    activeMarkerElements.current.delete(element);
22✔
329
    if (supportsCreateRoot) {
22!
330
      const root = markerRoots.current.get(element);
19✔
331
      if (root) {
22✔
332
        // unmount must be called after the current render finishes, so schedule it for the next
333
        // microtask
334
        scheduleRootUnmount(root);
15✔
335
        markerRoots.current.delete(element);
15✔
336
      }
337
    } else {
338
      legacyReactDOM.unmountComponentAtNode?.(element);
13✔
339
    }
340
  }, [scheduleRootUnmount]);
341

342
  const attachPinComponent = useCallback((element: HTMLElement, component: React.JSX.Element) => {
24✔
343
    if (supportsCreateRoot && typeof ReactDomClient.createRoot === 'function') {
17!
344
      // Use React 18+ API
345
      let root = markerRoots.current.get(element);
10✔
346
      if (!root) {
17✔
347
        root = ReactDomClient.createRoot(element);
17✔
348
        markerRoots.current.set(element, root);
17✔
349
      }
350
      root.render(component);
17✔
351
    } else if (typeof legacyReactDOM.render === 'function') {
13!
352
      // Fallback for React <18
353
      legacyReactDOM.render(component, element);
13✔
354
    }
355
  }, []);
356

357
  // builds and attaches a single marker to the mapbox map
358
  const createMarker = useCallback((
24✔
359
    mapbox: MapInstance,
360
    result: Result<T>,
361
    index: number,
362
    selected: boolean
363
  ) => {
364
    const markerLocation = getCoordinate(result);
27✔
365
    if (!markerLocation) {
24!
366
      return null;
15✔
367
    }
368
    const { latitude, longitude } = markerLocation;
25✔
369
    if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
22!
370
      return null;
13✔
371
    }
372

373
    const el = document.createElement('div');
25✔
374
    let markerOptions: mapboxgl.MarkerOptions = {};
25✔
375
    if (PinComponent) {
22✔
376
      if (renderPin) {
15!
377
        console.warn(
14✔
378
          'Found both PinComponent and renderPin props. Using PinComponent.'
379
        );
380
      }
381
      attachPinComponent(el, (
15✔
382
        <PinComponent
383
          index={index}
384
          mapbox={mapbox}
385
          result={result}
386
          selected={selected}
387
        />
388
      ));
389
      markerOptions.element = el;
15✔
390
    } else if (renderPin) {
20!
391
      renderPin({ index, mapbox, result, container: el });
13✔
392
      markerOptions.element = el;
13✔
393
    }
394

395
    if (markerOptionsOverride) {
22!
396
      markerOptions = {
13✔
397
        ...markerOptions,
398
        ...toNativeMarkerOptions(markerOptionsOverride(selected))
399
      };
400
    }
401

402
    const nativeMap = mapbox.getNativeInstance();
25✔
403
    if (!(nativeMap instanceof mapboxInstance.Map)) {
22!
404
      return null;
13✔
405
    }
406

407
    const marker = new mapboxInstance.Marker(markerOptions)
25✔
408
      .setLngLat({ lat: latitude, lng: longitude })
409
      .addTo(nativeMap);
410

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

413
    return { marker, location: markerLocation };
22✔
414
  }, [
415
    PinComponent,
416
    attachPinComponent,
417
    getCoordinate,
418
    handlePinClick,
419
    mapboxInstance,
420
    markerOptionsOverride,
421
    renderPin
422
  ]);
423

424
  const removeMarkers = useCallback(() => {
24✔
425
    markers.current.forEach(marker => {
35✔
426
      if (!marker) {
22!
427
        return;
13✔
428
      }
429
      const element = marker?.getElement?.();
19✔
430
      if (element) {
22✔
431
        cleanupPinComponent(element);
22✔
432
      }
433
      if (typeof marker.remove === 'function') {
22✔
434
        marker.remove();
22✔
435
      }
436
    });
437
    markers.current = [];
35✔
438
    markerData.current = [];
35✔
439
  }, [cleanupPinComponent]);
440

441
  const locale = useSearchState(state => state.meta?.locale);
24✔
442
  // keep track of the previous value of mapboxOptions across renders
443
  const prevMapboxOptions = useRef(mapboxOptions);
24✔
444

445
  /**
446
   * Localizes Mapbox label text to a specific locale.
447
   *
448
   * Updates symbol layers that are place names such that labels prefer `name_<lang>`
449
   * (e.g. `name_fr`) and fall back to `name` when unavailable.
450
   *
451
   * Note:
452
   * - Symbol layers that are place names would have `text-field` properties that includes
453
   *   'name', which are localized.
454
   * - Other symbol layers (e.g. road shields, transit, icons) are left unchanged.
455
   */
456
  const localizeMap = useCallback(() => {
24✔
457
    const mapbox = map.current;
21✔
458
    if (!mapbox || !locale) return;
24✔
459

460
    const localizeLabels = () => {
×
461
      mapbox.getStyle().layers.forEach(layer => {
×
462
        if (layer.type !== 'symbol') {
×
463
          return;
×
464
        }
465
        const textField = layer.layout?.['text-field'];
×
466
        if (typeof textField === 'string'
×
467
          ? textField.includes('name')
468
          : (Array.isArray(textField) && JSON.stringify(textField).includes('name'))) {
×
469
          mapbox.setLayoutProperty(
×
470
            layer.id,
471
            'text-field',
472
            [
473
              'coalesce',
474
              ['get', `name_${getMapboxLanguage(locale)}`],
475
              ['get', 'name']
476
            ]
477
          );
478
        }
479
      });
480
    };
481

482
    if (mapbox.isStyleLoaded()) {
13!
483
      localizeLabels();
13✔
484
    } else {
485
      mapbox.once('styledata', () => localizeLabels());
13✔
486
    }
487
  }, [locale]);
488

489
  // initialize the map once and update mapbox options when allowUpdates is true.
490
  useEffect(() => {
13✔
491
    if (mapContainer.current) {
11✔
492
      if (map.current && allowUpdates) {
11!
493
        // Compare current and previous mapboxOptions using deep equality
494
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
1!
495
          // Update to existing Map
496
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
1✔
497
          prevMapboxOptions.current = mapboxOptions;
1✔
498
        }
499
      } else if (!map.current && mapboxInstance) {
10✔
500
        const options: mapboxgl.MapOptions = {
20✔
501
          container: mapContainer.current,
502
          style: 'mapbox://styles/mapbox/streets-v11',
503
          center: [-74.005371, 40.741611],
504
          zoom: 9,
505
          ...toNativeMapboxOptions(mapboxOptions)
506
        };
507
        map.current = new mapboxInstance.Map(options);
10✔
508
        const nativeMap = map.current;
20✔
509
        mapFacade.current = createMapInstanceFacade(nativeMap);
10✔
510
        nativeMap.resize();
10✔
511
        const nav = new mapboxInstance.NavigationControl({
20✔
512
          showCompass: false,
513
          showZoom: true,
514
          visualizePitch: false
515
        });
516
        nativeMap.addControl(nav, 'top-right');
10✔
517
      }
518
      localizeMap();
11✔
519
    }
520
  }, [allowUpdates, localizeMap, mapboxInstance, mapboxOptions]);
521

522
  // Register movement listeners separately from map initialization so rerenders do not
523
  // accidentally remove them without reattaching them.
524
  useEffect(() => {
13✔
525
    const nativeMap = map.current;
20✔
526
    if (!nativeMap || !onDragDebounced) {
10!
527
      return;
9✔
528
    }
529
    const canvasContainer = nativeMap.getCanvasContainer();
11✔
530
    let keyboardMovePending = false;
11✔
531

532
    const dispatchDrag = () => {
11✔
533
      const bounds = nativeMap.getBounds();
1✔
534
      if (!bounds) {
11!
535
        return;
10✔
536
      }
537
      onDragDebounced(toMapCenter(nativeMap.getCenter()), toMapBounds(bounds));
11✔
538
    };
539
    const onKeyDown = (event: KeyboardEvent) => {
11✔
540
      if (KEYBOARD_MOVE_KEYS.has(event.key)) {
10!
541
        keyboardMovePending = true;
10✔
542
      }
543
    };
544
    const clearPendingKeyboardMove = () => {
11✔
545
      keyboardMovePending = false;
10✔
546
    };
547
    const onDrag = () => {
11✔
548
      dispatchDrag();
11✔
549
    };
550
    const onMove = (e: mapboxgl.MapEventOf<'move'>) => {
11✔
551
      if (keyboardMovePending || ('originalEvent' in e && e.originalEvent)) {
60!
552
        // only trigger on user movement, not programmatic movement (e.g. from fitBounds)
553
        dispatchDrag();
10✔
554
      }
555
    };
556

557
    canvasContainer?.addEventListener('keydown', onKeyDown);
10✔
558
    nativeMap.on('drag', onDrag);
10✔
559
    nativeMap.on('move', onMove);
10✔
560
    nativeMap.on('moveend', clearPendingKeyboardMove);
10✔
561

562
    return () => {
10✔
563
      keyboardMovePending = false;
6✔
564
      canvasContainer?.removeEventListener('keydown', onKeyDown);
6✔
565
      nativeMap.off('drag', onDrag);
6✔
566
      nativeMap.off('move', onMove);
6✔
567
      nativeMap.off('moveend', clearPendingKeyboardMove);
6✔
568
    };
569
  }, [onDragDebounced]);
570

571
  // resize the map when its iframe container changes size.
572
  useEffect(() => {
13✔
573
    if (iframeWindow && map.current) {
10!
574
      map.current.resize();
×
575
    }
576
  }, [iframeWindow]);
577

578
  // create and place markers when results change, then cleanup on teardown
579
  useEffect(() => {
13✔
580
    removeMarkers();
11✔
581
    const nativeMap = map.current;
21✔
582
    const mapbox = mapFacade.current;
21✔
583
    if (nativeMap && mapbox && locationResults) {
11✔
584
      if (locationResults.length > 0) {
11!
585
        const bounds = new mapboxInstance.LngLatBounds();
21✔
586
        // create a marker for each result
587
        locationResults.forEach((result, i) => {
11✔
588
          const created = createMarker(mapbox, result, i, false);
27✔
589
          if (!created) {
16!
590
            return;
2✔
591
          }
592
          markers.current.push(created.marker);
16✔
593
          markerData.current.push({ marker: created.marker, result, index: i });
16✔
594
          bounds.extend([created.location.longitude, created.location.latitude]);
16✔
595
        });
596

597
        // fit the map to the markers
598
        nativeMap.resize();
11✔
599
        const canvas = nativeMap.getCanvas();
21✔
600

601
        // add padding to map
602
        if (!bounds.isEmpty()
11✔
603
            && !!canvas
604
            && canvas.clientHeight > 0
605
            && canvas.clientWidth > 0
606
        ) {
607
          const resolvedOptions = {
21✔
608
            // these settings are defaults and will be overriden if present on fitBoundsOptions
609
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
610
            maxZoom: mapboxOptions?.maxZoom ?? 15,
42✔
611
            ...toNativeFitBoundsOptions(mapboxOptions?.fitBoundsOptions),
612
          };
613

614
          let resolvedPadding;
615
          if (typeof resolvedOptions.padding === 'number') {
11!
616
            resolvedPadding = {
×
617
              top: resolvedOptions.padding,
618
              bottom: resolvedOptions.padding,
619
              left: resolvedOptions.padding,
620
              right: resolvedOptions.padding
621
            };
622
          } else {
623
            resolvedPadding = {
11✔
624
              top: resolvedOptions.padding?.top ?? 0,
21!
625
              bottom: resolvedOptions.padding?.bottom ?? 0,
21!
626
              left: resolvedOptions.padding?.left ?? 0,
21!
627
              right: resolvedOptions.padding?.right ?? 0
21!
628
            };
629
          }
630

631
          // Padding must not exceed the map's canvas dimensions
632
          const verticalPaddingSum = resolvedPadding.top + resolvedPadding.bottom;
21✔
633
          if (verticalPaddingSum >= canvas.clientHeight) {
11!
634
            const ratio = canvas.clientHeight / (verticalPaddingSum || 1);
×
635
            resolvedPadding.top = Math.max(0, resolvedPadding.top * ratio - 1);
×
636
            resolvedPadding.bottom = Math.max(0, resolvedPadding.bottom * ratio - 1);
×
637
          }
638
          const horizontalPaddingSum = resolvedPadding.left + resolvedPadding.right;
21✔
639
          if (horizontalPaddingSum >= canvas.clientWidth) {
11!
640
            const ratio = canvas.clientWidth / (horizontalPaddingSum || 1);
×
641
            resolvedPadding.left = Math.max(0, resolvedPadding.left * ratio - 1);
×
642
            resolvedPadding.right = Math.max(0, resolvedPadding.right * ratio - 1);
×
643
          }
644
          resolvedOptions.padding = resolvedPadding;
11✔
645
          nativeMap.fitBounds(bounds, resolvedOptions);
11✔
646
        }
647

648
        // return a cleanup function to remove markers when the map component unmounts
649
        return () => {
11✔
650
          markers.current.forEach((marker, i) => {
11✔
651
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
10✔
652
          });
653
          removeMarkers();
11✔
654
        };
655
      } else if (staticFilters?.length) {
×
656
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
657
        if (locationFilterValue) {
×
NEW
658
          nativeMap.flyTo({
×
659
            center: locationFilterValue
660
          });
661
        }
662
      }
663
    }
664
  }, [
665
    createMarker,
666
    handlePinClick,
667
    locationResults,
668
    mapboxInstance,
669
    mapboxOptions,
670
    removeMarkers,
671
    staticFilters
672
  ]);
673

674
  const previousSelectedResult = useRef<Result<T> | undefined>(undefined);
24✔
675

676
  // update marker options when markerOptionsOverride changes or selectedResult changes
677
  useEffect(() => {
13✔
678
    const mapbox = mapFacade.current;
23✔
679
    if (!mapbox || !markerOptionsOverride) {
13✔
680
      previousSelectedResult.current = selectedResult;
13✔
681
      return;
13✔
682
    }
683

684
    const prevSelected = previousSelectedResult.current;
×
685
    previousSelectedResult.current = selectedResult;
×
686

687
    // markerOptionsOverride is applied at creation time, so we recreate only the affected
688
    // markers to reflect selection changes without tearing down all pins.
689
    const resultsToUpdate = new Set<Result<T>>();
×
690
    if (prevSelected) {
×
691
      resultsToUpdate.add(prevSelected);
×
692
    }
693
    if (selectedResult) {
×
694
      resultsToUpdate.add(selectedResult);
×
695
    }
696

697
    resultsToUpdate.forEach((result) => {
×
698
      const markerEntry = markerData.current.find(entry => entry.result === result);
×
699
      if (!markerEntry) {
×
700
        return;
×
701
      }
702
      // recreate the marker to apply new markerOptionsOverride (e.g. color/scale).
703
      const oldMarker = markerEntry.marker;
×
704
      const element = oldMarker?.getElement?.();
×
705
      if (element) {
×
706
        cleanupPinComponent(element);
×
707
      }
708
      oldMarker?.remove?.();
×
709

710
      const created = createMarker(mapbox, result, markerEntry.index, selectedResult === result);
×
711
      if (!created) {
×
712
        return;
×
713
      }
714
      markerEntry.marker = created.marker;
×
715
      markers.current[markerEntry.index] = created.marker;
×
716
    });
717
  }, [cleanupPinComponent, createMarker, markerOptionsOverride, selectedResult]);
718

719
  // re-render custom PinComponent on selection changes to update the visual state
720
  useEffect(() => {
13✔
721
    const mapbox = mapFacade.current;
23✔
722
    if (!mapbox || !PinComponent) {
13✔
723
      return;
9✔
724
    }
725
    markerData.current.forEach(({ marker, result, index }) => {
4✔
726
      const element = marker?.getElement?.();
6✔
727
      if (!element) {
4!
UNCOV
728
        return;
×
729
      }
730
      attachPinComponent(element, (
4✔
731
        <PinComponent
732
          index={index}
733
          mapbox={mapbox}
734
          result={result}
735
          selected={selectedResult === result}
736
        />
737
      ));
738
    });
739
  }, [attachPinComponent, PinComponent, selectedResult]);
740

741
  return (
13✔
742
    <div ref={mapContainer} className='h-full w-full' />
743
  );
744
}
745

746
function toMapCenter(lngLat: mapboxgl.LngLat): MapCenter {
747
  const coordinate = {
4✔
748
    latitude: lngLat.lat,
749
    longitude: lngLat.lng
750
  };
751

752
  return {
4✔
753
    ...coordinate,
NEW
754
    wrap: () => toMapCenter(lngLat.wrap()),
×
NEW
755
    toArray: () => lngLat.toArray(),
×
NEW
756
    toString: () => lngLat.toString(),
×
757
    distanceTo: (nextCoordinate: Coordinate) => lngLat.distanceTo(
4✔
758
      new mapboxgl.LngLat(nextCoordinate.longitude, nextCoordinate.latitude)
759
    ),
NEW
760
    toBounds: (radius?: number) => toMapBounds(lngLat.toBounds(radius)),
×
NEW
761
    toEcef: (altitude: number) => lngLat.toEcef(altitude)
×
762
  };
763
}
764

765
function toMapBounds(bounds: mapboxgl.LngLatBounds): MapBounds {
766
  return {
1✔
767
    setNorthEast: (coordinate: Coordinate) => toMapBounds(
1✔
768
      bounds.setNorthEast(toNativeCoordinate(coordinate))
769
    ),
770
    setSouthWest: (coordinate: Coordinate) => toMapBounds(
1✔
771
      bounds.setSouthWest(toNativeCoordinate(coordinate))
772
    ),
773
    extend: (coordinateOrBounds: Coordinate | MapBounds) => toMapBounds(
1✔
774
      bounds.extend(
775
        'getNorthEast' in coordinateOrBounds
×
776
          ? [
777
            toNativeCoordinate(coordinateOrBounds.getSouthWest()),
778
            toNativeCoordinate(coordinateOrBounds.getNorthEast())
779
          ]
780
          : toNativeCoordinate(coordinateOrBounds)
781
      )
782
    ),
NEW
783
    getCenter: () => toMapCenter(bounds.getCenter()),
×
NEW
784
    getSouthWest: () => toMapCenter(bounds.getSouthWest()),
×
785
    getNorthEast: () => toMapCenter(bounds.getNorthEast()),
2✔
NEW
786
    getNorthWest: () => toMapCenter(bounds.getNorthWest()),
×
NEW
787
    getSouthEast: () => toMapCenter(bounds.getSouthEast()),
×
NEW
788
    getWest: () => bounds.getWest(),
×
NEW
789
    getSouth: () => bounds.getSouth(),
×
NEW
790
    getEast: () => bounds.getEast(),
×
NEW
791
    getNorth: () => bounds.getNorth(),
×
NEW
792
    toArray: () => bounds.toArray(),
×
NEW
793
    toString: () => bounds.toString(),
×
NEW
794
    isEmpty: () => bounds.isEmpty(),
×
NEW
795
    contains: (coordinate: Coordinate) => bounds.contains(toNativeCoordinate(coordinate))
×
796
  };
797
}
798

799
function toNativeCoordinate(coordinate: Coordinate): [number, number] {
800
  return [coordinate.longitude, coordinate.latitude];
1✔
801
}
802

803
function toNativeFitBoundsOptions(
804
  fitBoundsOptions: MapFitBoundsOptions | undefined
805
): mapboxgl.MapOptions['fitBoundsOptions'] | undefined {
806
  if (!fitBoundsOptions) {
14✔
807
    return undefined;
12✔
808
  }
809

810
  return {
2✔
811
    ...fitBoundsOptions,
812
    padding: fitBoundsOptions.padding
2!
813
      ? typeof fitBoundsOptions.padding === 'number'
2!
814
        ? fitBoundsOptions.padding
815
        : {
816
          top: fitBoundsOptions.padding.top ?? 0,
2!
817
          bottom: fitBoundsOptions.padding.bottom ?? 0,
2!
818
          left: fitBoundsOptions.padding.left ?? 0,
2!
819
          right: fitBoundsOptions.padding.right ?? 0
2!
820
        }
821
      : undefined
822
  };
823
}
824

825
function toNativeMapboxOptions(mapboxOptions: MapboxMapOptions | undefined): Omit<mapboxgl.MapOptions, 'container'> {
826
  if (!mapboxOptions) {
10✔
827
    return {};
10✔
828
  }
829

830
  return {
3✔
831
    ...mapboxOptions,
832
    center: mapboxOptions.center ? toNativeCoordinate(mapboxOptions.center) : undefined,
3✔
833
    fitBoundsOptions: toNativeFitBoundsOptions(mapboxOptions.fitBoundsOptions),
834
    style: mapboxOptions.style as mapboxgl.StyleSpecification | string | undefined
835
  };
836
}
837

838
function toNativeMarkerOptions(markerOptions: MapMarkerOptions): mapboxgl.MarkerOptions {
NEW
839
  return { ...markerOptions };
×
840
}
841

842
function createMapInstanceFacade(map: mapboxgl.Map): MapInstance {
843
  return {
10✔
844
    fitBounds: (bounds, options) => {
NEW
845
      map.fitBounds(
×
846
        [
847
          toNativeCoordinate(bounds.getSouthWest()),
848
          toNativeCoordinate(bounds.getNorthEast())
849
        ],
850
        toNativeFitBoundsOptions(options)
851
      );
852
    },
853
    flyTo: ({ center }) => {
NEW
854
      map.flyTo({ center: toNativeCoordinate(center) });
×
855
    },
856
    getBounds: () => {
NEW
857
      const bounds = map.getBounds();
×
NEW
858
      return bounds ? toMapBounds(bounds) : undefined;
×
859
    },
860
    getCenter: () => toMapCenter(map.getCenter()),
1✔
861
    getNativeInstance: () => map,
28✔
862
    resize: () => {
NEW
863
      map.resize();
×
864
    }
865
  };
866
}
867

868
function handleMapboxOptionsUpdates(mapboxOptions: MapboxMapOptions | undefined, currentMap: mapboxgl.Map) {
869
  if (mapboxOptions?.style) {
1!
870
    currentMap.setStyle(mapboxOptions.style as mapboxgl.StyleSpecification | string);
1✔
871
  }
872
  // Add more options to update as needed
873
}
874

875
function isCoordinate(data: unknown): data is Coordinate {
876
  return typeof data == 'object'
16✔
877
    && typeof (data as any)?.['latitude'] === 'number'
878
    && typeof (data as any)?.['longitude'] === 'number';
879
}
880

881
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
882
  const yextDisplayCoordinate: Coordinate = (result.rawData as any)['yextDisplayCoordinate'];
26✔
883
  if (!yextDisplayCoordinate) {
16!
884
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
885
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
886
    return undefined;
1✔
887
  }
888
  if (!isCoordinate(yextDisplayCoordinate)) {
16!
889
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
890
    return undefined;
1✔
891
  }
892
  return yextDisplayCoordinate;
16✔
893
}
894

895
export function getMapboxLanguage(locale: string) {
7✔
896
  try {
22✔
897
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
898
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
899
  } catch (e) {
900
    console.warn(`Locale "${locale}" is not supported.`);
×
901
  }
902
  return 'en';
×
903
}
904

905
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
906
  const locationFilter = staticFilters.find(f => (f.filter as any)['fieldId'] === 'builtin.location' && (f.filter as any)['value'])?.filter;
×
907
  if (locationFilter) {
×
908
    const { lat, lng } = (locationFilter as any)['value'];
×
909
    return [lng, lat];
×
910
  }
911
}
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