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

yext / search-ui-react / 21011576253

14 Jan 2026 10:06PM UTC coverage: 85.297% (+0.009%) from 85.288%
21011576253

push

github

web-flow
chore: add linting configuration and hook (#610)

* add linter; update all files with correct linting

* add husky config for pre-commit validation

* rerun npm i in test site

* Update snapshots

* Update snapshots

* Update snapshots

* fix dropdown item widths; don't scroll through dropdown items on tab

* Update snapshots

* fix test for dropdown tab behavior change

* update storybook test to not use tab to cycle through dropdown

* Update snapshots

* minor style nit

---------

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

998 of 1383 branches covered (72.16%)

Branch coverage included in aggregate %.

81 of 89 new or added lines in 24 files covered. (91.01%)

8 existing lines in 1 file now uncovered.

2245 of 2419 relevant lines covered (92.81%)

149.65 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

428
        return () => {
13✔
429
          markers.current.forEach((marker, i) => {
9✔
430
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
13✔
431
          });
432
          removeMarkers();
9✔
433
        };
434
      } else if (staticFilters?.length) {
×
435
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
436
        if (locationFilterValue) {
×
437
          mapbox.flyTo({
×
438
            center: locationFilterValue
439
          });
440
        }
441
      }
442
    }
443
  }, [
444
    PinComponent,
445
    attachPinComponent,
446
    getCoordinate,
447
    handlePinClick,
448
    locationResults,
449
    mapboxInstance,
450
    mapboxOptions,
451
    markerOptionsOverride,
452
    removeMarkers,
453
    renderPin,
454
    selectedResult,
455
    staticFilters
456
  ]);
457

458
  useEffect(() => {
13✔
459
    const mapbox = map.current;
19✔
460
    if (!mapbox || !PinComponent) {
13✔
461
      return;
9✔
462
    }
463
    markerData.current.forEach(({ marker, result, index }) => {
4✔
464
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
4!
465
      if (!element) {
4!
466
        return;
×
467
      }
468
      attachPinComponent(element, (
4✔
469
        <PinComponent
470
          index={index}
471
          mapbox={mapbox}
472
          result={result}
473
          selected={selectedResult === result}
474
        />
475
      ));
476
    });
477
  }, [attachPinComponent, PinComponent, selectedResult]);
478

479
  return (
13✔
480
    <div ref={mapContainer} className='h-full w-full' />
481
  );
482
}
483

484
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
485
  if (mapboxOptions?.style) {
×
486
    currentMap.setStyle(mapboxOptions.style);
×
487
  }
488
  // Add more options to update as needed
489
}
490

491
function isCoordinate(data: unknown): data is Coordinate {
492
  return typeof data == 'object'
19✔
493
    && typeof (data as any)?.['latitude'] === 'number'
494
    && typeof (data as any)?.['longitude'] === 'number';
495
}
496

497
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
498
  const yextDisplayCoordinate: Coordinate = (result.rawData as any)['yextDisplayCoordinate'];
23✔
499
  if (!yextDisplayCoordinate) {
19!
500
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
501
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
502
    return undefined;
1✔
503
  }
504
  if (!isCoordinate(yextDisplayCoordinate)) {
19!
505
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
506
    return undefined;
1✔
507
  }
508
  return yextDisplayCoordinate;
19✔
509
}
510

511
export function getMapboxLanguage(locale: string) {
6✔
512
  try {
22✔
513
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
514
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
515
  } catch (e) {
NEW
516
    console.warn(`Locale "${locale}" is not supported.`);
×
517
  }
518
  return 'en';
×
519
}
520

521
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
522
  const locationFilter = staticFilters.find(f => (f.filter as any)['fieldId'] === 'builtin.location' && (f.filter as any)['value'])?.filter;
×
523
  if (locationFilter) {
×
NEW
524
    const { lat, lng } = (locationFilter as any)['value'];
×
525
    return [lng, lat];
×
526
  }
527
}
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