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

yext / search-ui-react / 19171186695

07 Nov 2025 02:21PM UTC coverage: 87.922% (-0.4%) from 88.287%
19171186695

push

github

web-flow
Merge main (v1.10.2) into develop (#575)

* 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

* rerun
up to date, audited 1940 packages in 3s

375 packages are looking for funding
  run `npm fund` for details

28 vulnerabilities (6 low, 17 moderate, 5 high)

To address issues ... (continued)

821 of 1033 branches covered (79.48%)

Branch coverage included in aggregate %.

30 of 38 new or added lines in 1 file covered. (78.95%)

2069 of 2254 relevant lines covered (91.79%)

135.59 hits per line

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

74.16
/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

9
/**
10
 * Props for rendering a custom marker on the map.
11
 *
12
 * @public
13
 */
14
export type PinComponentProps<T> = {
15
  /** The index of the pin. */
16
  index: number,
17
  /** The Mapbox map. */
18
  mapbox: mapboxgl.Map,
19
  /** The search result corresponding to the pin. */
20
  result: Result<T>,
21
  /** Where the pin is selected. */
22
  selected?: boolean,
23
};
24

25
/**
26
 * A functional component that can be used to render a custom marker on the map.
27
 *
28
 * @public
29
 */
30
export type PinComponent<T> = (props: PinComponentProps<T>) => JSX.Element;
31

32
/**
33
 * A function use to derive a result's coordinate.
34
 *
35
 * @public
36
 */
37
export type CoordinateGetter<T> = (result: Result<T>) => Coordinate | undefined;
38

39
/**
40
 * Coordinate use to represent the result's location on a map.
41
 *
42
 * @public
43
 */
44
export interface Coordinate {
45
  /** The latitude of the location. */
46
  latitude: number,
47
  /** The longitude of the location. */
48
  longitude: number
49
}
50

51
/**
52
 * A function which is called when user drags or zooms the map.
53
 *
54
 * @public
55
 */
56
export type OnDragHandler = (center: mapboxgl.LngLat, bounds: mapboxgl.LngLatBounds) => void;
57

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

110
/**
111
 * A component that renders a map with markers to show result locations using Mapbox GL.
112
 *
113
 * @remarks
114
 * For the map to work properly, be sure to include Mapbox GL stylesheet in the application.
115
 *
116
 * @example
117
 * For instance, user may add the following import statement in their application's index file
118
 * or in the file where `MapboxMap` is used:
119
 * `import 'mapbox-gl/dist/mapbox-gl.css';`
120
 *
121
 * Or, user may add a stylesheet link in their html page:
122
 * `<link href="https://api.mapbox.com/mapbox-gl-js/v2.9.2/mapbox-gl.css" rel="stylesheet" />`
123
 *
124
 * @param props - {@link MapboxMapProps}
125
 * @returns A React element containing a Mapbox Map
126
 *
127
 * @public
128
 */
129
export function MapboxMap<T>({
6✔
130
  mapboxAccessToken,
131
  mapboxOptions,
132
  PinComponent,
133
  renderPin,
134
  getCoordinate = getDefaultCoordinate,
20✔
135
  onDrag,
136
  iframeWindow,
137
  allowUpdates = false,
21✔
138
  onPinClick,
139
  markerOptionsOverride,
140
}: MapboxMapProps<T>): JSX.Element {
141
  const mapboxInstance = (iframeWindow as Window & { mapboxgl?: typeof mapboxgl })?.mapboxgl ?? mapboxgl;
21✔
142
  useEffect(() => {
21✔
143
    mapboxInstance.accessToken = mapboxAccessToken;
16✔
144
  }, [mapboxAccessToken]);
145

146
  const mapContainer = useRef<HTMLDivElement>(null);
21✔
147
  const map = useRef<mapboxgl.Map | null>(null);
21✔
148
  const markers = useRef<mapboxgl.Marker[]>([]);
21✔
149

150
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
34✔
151
  const staticFilters = useSearchState(state => state.filters?.static);
34✔
152
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
21✔
153
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
21✔
154

155
  const handlePinClick = useCallback((result: Result<T>) => {
21✔
156
    setSelectedResult(prev => prev === result ? undefined : result)
9!
157
  }, [])
158

159
  useEffect(() => {
15✔
160
    onPinClick?.(selectedResult);
19✔
161
  }, [selectedResult])
162

163
  const locale = useSearchState(state => state.meta?.locale);
34✔
164
  // keep track of the previous value of mapboxOptions across renders
165
  const prevMapboxOptions = useRef(mapboxOptions);
21✔
166

167
  const localizeMap = useCallback(() => {
21✔
168
    const mapbox = map.current;
10✔
169
    if (!mapbox || !locale) return;
10✔
170

171
    const localizeLabels = () => {
×
172
      mapbox.getStyle().layers.forEach(layer => {
×
173
        if (layer.type === "symbol" && layer.layout?.["text-field"]) {
×
174
          mapbox.setLayoutProperty(
×
175
            layer.id,
176
            "text-field",
177
            [
178
              'coalesce',
179
              ['get', `name_${getMapboxLanguage(locale)}`],
180
              ['get', 'name']
181
            ]
182
          );
183
        }
184
      });
185
    }
186

187
    if (mapbox.isStyleLoaded()) {
×
188
      localizeLabels();
×
189
    } else {
190
      mapbox.once("styledata", () => localizeLabels())
×
191
    }
192
  }, [locale]);
193

194
  useEffect(() => {
21✔
195
    if (mapContainer.current) {
21✔
196
      if (map.current && allowUpdates) {
21!
197
        // Compare current and previous mapboxOptions using deep equality
198
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
×
199
          // Update to existing Map
200
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
201
          prevMapboxOptions.current = (mapboxOptions);
×
202
        }
203
      } else if (!map.current && mapboxInstance) {
21✔
204
        const options: mapboxgl.MapboxOptions = {
21✔
205
          container: mapContainer.current,
206
          style: 'mapbox://styles/mapbox/streets-v11',
207
          center: [-74.005371, 40.741611],
208
          zoom: 9,
209
          ...mapboxOptions
210
        };
211
        map.current = new mapboxInstance.Map(options);
16✔
212
        const mapbox = map.current;
16✔
213
        mapbox.resize();
16✔
214
        const nav = new mapboxInstance.NavigationControl({
16✔
215
          showCompass: false,
216
          showZoom: true,
217
          visualizePitch: false
218
        });
219
        mapbox.addControl(nav, 'top-right');
16✔
220
        if (onDragDebounced) {
16✔
221
          mapbox.on('drag', () => {
11✔
222
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
223
          });
224
          mapbox.on('zoom', (e) => {
11✔
225
            if (e.originalEvent) {
204!
226
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
227
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
228
            }
229
          });
230
          return () => {
11✔
231
            mapbox.off('drag', () => {
9✔
232
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
233
            });
234
            mapbox.off('zoom', (e) => {
9✔
235
              if (e.originalEvent) {
×
236
                onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
237
              }
238
            });
239
          };
240
        }
241
      }
242
      localizeMap();
10✔
243
    }
244
  }, [mapboxOptions, onDragDebounced, localizeMap]);
245

246
  useEffect(() => {
21✔
247
    if (iframeWindow && map.current) {
19!
248
      map.current.resize();
×
249
    }
250
  }, [mapContainer.current]);
251

252
  useEffect(() => {
21✔
253
    markers.current.forEach(marker => marker.remove());
21✔
254
    markers.current = [];
21✔
255
    const mapbox = map.current;
21✔
256
    if (mapbox && locationResults) {
21✔
257
      if (locationResults.length > 0) {
20!
258
        const bounds = new mapboxInstance.LngLatBounds();
20✔
259
        locationResults.forEach((result, i) => {
20✔
260
          const markerLocation = getCoordinate(result);
26✔
261
          if (markerLocation) {
26✔
262
            const { latitude, longitude } = markerLocation;
24✔
263
            const el = document.createElement('div');
24✔
264
            let markerOptions: mapboxgl.MarkerOptions = {};
24✔
265
            if (PinComponent) {
24✔
266
              if (renderPin) {
7✔
267
                console.warn(
1✔
268
                  'Found both PinComponent and renderPin props. Using PinComponent.'
269
                );
270
              }
271
              ReactDOM.render(<PinComponent
7✔
272
                index={i}
273
                mapbox={mapbox}
274
                result={result}
275
                selected={selectedResult === result}
276
              />, el);
277
              markerOptions.element = el;
7✔
278
            } else if (renderPin) {
17✔
279
              renderPin({ index: i, mapbox, result, container: el });
2✔
280
              markerOptions.element = el;
2✔
281
            }
282

283
            if (markerOptionsOverride) {
24!
284
              markerOptions = {
3✔
285
                ...markerOptions,
286
                ...markerOptionsOverride(selectedResult === result)
287
              }
288
            }
289

290
            const marker = new mapboxInstance.Marker(markerOptions)
24✔
291
              .setLngLat({ lat: latitude, lng: longitude })
292
              .addTo(mapbox);
293

294
            marker?.getElement().addEventListener('click', () => handlePinClick(result));
24✔
295
            markers.current.push(marker);
24✔
296
            bounds.extend([longitude, latitude]);
24✔
297
          }
298
        })
299

300
        if (!bounds.isEmpty()){
20✔
301
          mapbox.fitBounds(bounds, {
20✔
302
            // these settings are defaults and will be overriden if present on fitBoundsOptions
303
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
304
            maxZoom: mapboxOptions?.maxZoom ?? 15,
40✔
305
            ...mapboxOptions?.fitBoundsOptions,
306
          });
307
        }
308

309
        return () => {
20✔
310
          markers.current.forEach((marker, i) => {
16✔
311
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
18✔
312
          });
313
        }
NEW
314
      } else if (staticFilters?.length) {
×
NEW
315
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
NEW
316
        if (locationFilterValue) {
×
NEW
317
          mapbox.flyTo({
×
318
            center: locationFilterValue
319
          });
320
        }
321
      };
322
    }
323
  }, [PinComponent, getCoordinate, locationResults, selectedResult, markerOptionsOverride]);
324

325
  return (
21✔
326
    <div ref={mapContainer} className='h-full w-full' />
327
  );
328
}
329

330
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
331
  if (mapboxOptions?.style) {
×
332
    currentMap.setStyle(mapboxOptions.style);
×
333
  }
334
  // Add more options to update as needed
335
}
336

337
function isCoordinate(data: unknown): data is Coordinate {
338
  return typeof data == 'object'
24✔
339
    && typeof data?.['latitude'] === 'number'
340
    && typeof data?.['longitude'] === 'number';
341
}
342

343
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
344
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
25✔
345
  if (!yextDisplayCoordinate) {
25✔
346
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
347
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
348
    return undefined;
1✔
349
  }
350
  if (!isCoordinate(yextDisplayCoordinate)) {
24✔
351
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
352
    return undefined;
1✔
353
  }
354
  return yextDisplayCoordinate;
23✔
355
}
356

357
export function getMapboxLanguage(locale: string) {
6✔
358
  try {
22✔
359
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
360
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
361
  } catch (e) {
362
    console.warn(`Locale "${locale}" is not supported.`)
22✔
363
  }
364
  return 'en';
×
365
}
366

367
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
NEW
368
  const locationFilter = staticFilters.find(f => f.filter['fieldId'] === 'builtin.location' && f.filter['value'])?.filter;
×
NEW
369
  if (locationFilter) {
×
NEW
370
    const {lat, lng} = locationFilter['value'];
×
NEW
371
    return [lng, lat];
×
372
  }
373
}
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