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

yext / search-ui-react / 20831358701

08 Jan 2026 08:51PM UTC coverage: 85.844% (-2.0%) from 87.818%
20831358701

push

github

web-flow
Merge main (v1.12.0) into develop (#599)

* Support trusted publishing with OIDC (#558)

* Bump search-headless-react version (to be published as v1.10.0) (#561)

which includes change that supports facetAllowlist query parameter for vertical searches

* release: v1.10.0-beta.0

* release: v1.10.0

* drop node 16 from github workflows (#567)

* drop node 16 from github workflows

We are dropping node 16 support, and have removed it from
the related slapshot reusable workflows already. This PR
drops it from the github workflows for this repo
J=none
TEST=none

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

---------

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

* v1.10.1 (#569)

* v1.10.1 (#565)

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

* feat: upgrade playwright version to avoid vuln

This PR ups the playwright version used to avoid a vuln

J=VULN-40423
TEST=compile

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* remove version upgrade workflow

* remove version change

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

---------

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

* release: v1.10.1

* Move map to search location when there are no results (v1.10.2) (#570)

J=WAT-5112
TEST=manual

---------

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

* release: v1.10.2

* Fix orphaned ARIA references when collapsible=false (#579)

Fix originally made in #576 by @chrissnyder2337

When collapsible={false}, FilterGroup now renders a plain div instead of... (continued)

1015 of 1380 branches covered (73.55%)

Branch coverage included in aggregate %.

117 of 121 new or added lines in 42 files covered. (96.69%)

2 existing lines in 1 file now uncovered.

2211 of 2378 relevant lines covered (92.98%)

137.91 hits per line

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

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

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

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

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

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

25

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

232
  const localizeMap = useCallback(() => {
19✔
233
    const mapbox = map.current;
8✔
234
    if (!mapbox || !locale) return;
18✔
235

236
    const localizeLabels = () => {
13✔
237
      mapbox.getStyle().layers.forEach(layer => {
13✔
238
        if (layer.type === "symbol" && layer.layout?.["text-field"]) {
13!
239
          mapbox.setLayoutProperty(
13✔
240
            layer.id,
241
            "text-field",
242
            [
243
              'coalesce',
244
              ['get', `name_${getMapboxLanguage(locale)}`],
245
              ['get', 'name']
246
            ]
247
          );
248
        }
249
      });
250
    }
251

252
    if (mapbox.isStyleLoaded()) {
13!
253
      localizeLabels();
13✔
254
    } else {
255
      mapbox.once("styledata", () => localizeLabels())
×
256
    }
257
  }, [locale]);
258

259
  useEffect(() => {
13✔
260
    if (mapContainer.current) {
13✔
261
      if (map.current && allowUpdates) {
13!
262
        // Compare current and previous mapboxOptions using deep equality
263
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
×
264
          // Update to existing Map
265
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
266
          prevMapboxOptions.current = (mapboxOptions);
×
267
        }
268
      } else if (!map.current && mapboxInstance) {
13✔
269
        const options: mapboxgl.MapboxOptions = {
16✔
270
          container: mapContainer.current,
271
          style: 'mapbox://styles/mapbox/streets-v11',
272
          center: [-74.005371, 40.741611],
273
          zoom: 9,
274
          ...mapboxOptions
275
        };
276
        map.current = new mapboxInstance.Map(options);
10✔
277
        const mapbox = map.current;
16✔
278
        mapbox.resize();
10✔
279
        const nav = new mapboxInstance.NavigationControl({
16✔
280
          showCompass: false,
281
          showZoom: true,
282
          visualizePitch: false
283
        });
284
        mapbox.addControl(nav, 'top-right');
10✔
285
        if (onDragDebounced) {
10✔
286
          mapbox.on('drag', () => {
10✔
287
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
288
          });
289
          mapbox.on('zoom', (e) => {
10✔
290
            if (e.originalEvent) {
148!
291
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
292
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
293
            }
294
          });
295
          return () => {
10✔
296
            mapbox.off('drag', () => {
8✔
297
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
298
            });
299
            mapbox.off('zoom', (e) => {
8✔
300
              if (e.originalEvent) {
×
301
                onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
302
              }
303
            });
304
          };
305
        }
306
      }
307
      localizeMap();
5✔
308
    }
309
  }, [mapboxOptions, onDragDebounced, localizeMap]);
310

311
  useEffect(() => {
13✔
312
    if (iframeWindow && map.current) {
13!
313
      map.current.resize();
×
314
    }
315
  }, [mapContainer.current]);
316

317
  useEffect(() => {
13✔
318
    removeMarkers();
10✔
319
    const mapbox = map.current;
16✔
320
    if (mapbox && locationResults) {
10✔
321
      if (locationResults.length > 0) {
15!
322
        const bounds = new mapboxInstance.LngLatBounds();
15✔
323
        locationResults.forEach((result, i) => {
15✔
324
          const markerLocation = getCoordinate(result);
21✔
325
          if (markerLocation) {
16✔
326
            const { latitude, longitude } = markerLocation;
19✔
327
            const el = document.createElement('div');
19✔
328
            let markerOptions: mapboxgl.MarkerOptions = {};
19✔
329
            if (PinComponent) {
16✔
330
              if (renderPin) {
11!
331
                console.warn(
11✔
332
                  'Found both PinComponent and renderPin props. Using PinComponent.'
333
                );
334
              }
335
              attachPinComponent(el, (
11✔
336
                <PinComponent
337
                  index={i}
338
                  mapbox={mapbox}
339
                  result={result}
340
                  selected={selectedResult === result}
341
                />
342
              ));
343
              markerOptions.element = el;
11✔
344
            } else if (renderPin) {
14✔
345
              renderPin({ index: i, mapbox, result, container: el });
10✔
346
              markerOptions.element = el;
10✔
347
            }
348

349
            if (markerOptionsOverride) {
16!
UNCOV
350
              markerOptions = {
×
351
                ...markerOptions,
352
                ...markerOptionsOverride(selectedResult === result)
353
              }
354
            }
355

356
            const marker = new mapboxInstance.Marker(markerOptions)
19✔
357
              .setLngLat({ lat: latitude, lng: longitude })
358
              .addTo(mapbox);
359

360
            marker?.getElement().addEventListener('click', () => handlePinClick(result));
16✔
361
            markers.current.push(marker);
16✔
362
            if (marker) {
16✔
363
              markerData.current.push({ marker, result, index: i });
16✔
364
            }
365
            bounds.extend([longitude, latitude]);
16✔
366
          }
367
        })
368

369
        const canvas = mapbox.getCanvas();
15✔
370
        if (!bounds.isEmpty() && !!canvas && canvas.height > 0 && canvas.width > 0) {
15✔
371
          const resolvedOptions = {
10✔
372
            // these settings are defaults and will be overriden if present on fitBoundsOptions
373
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
374
            maxZoom: mapboxOptions?.maxZoom ?? 15,
20✔
375
            ...mapboxOptions?.fitBoundsOptions,
376
          };
377

378
          let resolvedPadding;
379
          if (typeof resolvedOptions.padding === 'number') {
10!
380
            resolvedPadding = { top: resolvedOptions.padding, bottom: resolvedOptions.padding, left: resolvedOptions.padding, right: resolvedOptions.padding };
10✔
381
          } else {
382
            resolvedPadding = {
10✔
383
              top: resolvedOptions.padding?.top ?? 0,
10!
384
              bottom: resolvedOptions.padding?.bottom ?? 0,
10!
385
              left: resolvedOptions.padding?.left ?? 0,
10!
386
              right: resolvedOptions.padding?.right ?? 0
10!
387
            };
388
          }
389

390
          // Padding must not exceed the map's canvas dimensions
391
          const verticalPaddingSum = resolvedPadding.top + resolvedPadding.bottom;
10✔
392
          if (verticalPaddingSum >= canvas.height) {
10!
NEW
393
            const ratio = canvas.height / (verticalPaddingSum || 1);
×
394
            resolvedPadding.top = Math.max(0, resolvedPadding.top * ratio - 1);
10✔
395
            resolvedPadding.bottom = Math.max(0, resolvedPadding.bottom * ratio - 1);
10✔
396
          }
397
          const horizontalPaddingSum = resolvedPadding.left + resolvedPadding.right;
10✔
398
          if (horizontalPaddingSum >= canvas.width) {
10!
NEW
399
            const ratio = canvas.width / (horizontalPaddingSum || 1);
×
400
            resolvedPadding.left = Math.max(0, resolvedPadding.left * ratio - 1);
10✔
401
            resolvedPadding.right = Math.max(0, resolvedPadding.right * ratio - 1);
10✔
402
          }
403
          resolvedOptions.padding = resolvedPadding;
10✔
404
          mapbox.fitBounds(bounds, resolvedOptions);
10✔
405
        }
406

407
        return () => {
15✔
408
          markers.current.forEach((marker, i) => {
15✔
409
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
13✔
410
          });
411
          removeMarkers();
15✔
412
        }
413
      } else if (staticFilters?.length) {
×
414
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
415
        if (locationFilterValue) {
×
416
          mapbox.flyTo({
×
417
            center: locationFilterValue
418
          });
419
        }
420
      };
421
    }
422
  }, [PinComponent, getCoordinate, locationResults, removeMarkers, markerOptionsOverride, renderPin]);
423

424
  useEffect(() => {
13✔
425
    const mapbox = map.current;
19✔
426
    if (!mapbox || !PinComponent) {
13✔
427
      return;
9✔
428
    }
429
    markerData.current.forEach(({ marker, result, index }) => {
4✔
430
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
4!
431
      if (!element) {
4!
NEW
432
        return;
×
433
      }
434
      attachPinComponent(element, (
4✔
435
        <PinComponent
436
          index={index}
437
          mapbox={mapbox}
438
          result={result}
439
          selected={selectedResult === result}
440
        />
441
      ));
442
    });
443
  }, [attachPinComponent, PinComponent, selectedResult]);
444

445
  return (
13✔
446
    <div ref={mapContainer} className='h-full w-full' />
447
  );
448
}
449

450
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
451
  if (mapboxOptions?.style) {
×
452
    currentMap.setStyle(mapboxOptions.style);
×
453
  }
454
  // Add more options to update as needed
455
}
456

457
function isCoordinate(data: unknown): data is Coordinate {
458
  return typeof data == 'object'
16✔
459
    && typeof data?.['latitude'] === 'number'
460
    && typeof data?.['longitude'] === 'number';
461
}
462

463
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
464
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
20✔
465
  if (!yextDisplayCoordinate) {
16!
466
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
467
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
468
    return undefined;
1✔
469
  }
470
  if (!isCoordinate(yextDisplayCoordinate)) {
16!
471
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
472
    return undefined;
1✔
473
  }
474
  return yextDisplayCoordinate;
16✔
475
}
476

477
export function getMapboxLanguage(locale: string) {
6✔
478
  try {
22✔
479
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
480
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
481
  } catch (e) {
UNCOV
482
    console.warn(`Locale "${locale}" is not supported.`)
×
483
  }
484
  return 'en';
×
485
}
486

487
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
488
  const locationFilter = staticFilters.find(f => f.filter['fieldId'] === 'builtin.location' && f.filter['value'])?.filter;
×
489
  if (locationFilter) {
×
490
    const {lat, lng} = locationFilter['value'];
×
491
    return [lng, lat];
×
492
  }
493
}
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