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

yext / search-ui-react / 15171874389

21 May 2025 08:25PM UTC coverage: 87.057% (-0.7%) from 87.751%
15171874389

Pull #520

github

web-flow
Merge fca9671d7 into 692649c5e
Pull Request #520: v1.8.7 Improve MapboxMap zoom, render, and styling functionality

1386 of 1725 branches covered (80.35%)

Branch coverage included in aggregate %.

5 of 8 new or added lines in 1 file covered. (62.5%)

1977 of 2138 relevant lines covered (92.47%)

111.37 hits per line

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

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

7
/**
8
 * Props for rendering a custom marker on the map.
9
 *
10
 * @public
11
 */
12
export type PinComponentProps<T> = {
13
  /** The index of the pin. */
14
  index: number,
15
  /** The Mapbox map. */
16
  mapbox: mapboxgl.Map,
17
  /** The search result corresponding to the pin. */
18
  result: Result<T>,
19
  /** Where the pin is selected. */
20
  selected?: boolean,
21
  /** A function that handles pin clicks. */
22
  onClick?: (result: Result<T>) => void
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 scrolls to the search result corresponding to the selected pin. */
105
  scrollToResult?: (result: Result<T> | undefined) => void,
106
  /**
107
   * The color that default map markers should be, in the form of a HEX color code.
108
   * By default, the standard Mapbox pin color is used, which is light blue (#3FB1CE).
109
   * This prop should not be used with {@link MapboxMapProps.PinComponent | PinComponent}
110
   * or with {@link MapboxMapProps.renderPin | renderPin}. If either are provided,
111
   * pinColor will be ignored.
112
  */
113
  pinColor?: string
114
}
115

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

152
  const mapContainer = useRef<HTMLDivElement>(null);
18✔
153
  const map = useRef<mapboxgl.Map | null>(null);
18✔
154
  const markers = useRef<mapboxgl.Marker[]>([]);
18✔
155

156
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
31✔
157
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
18✔
158
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
18✔
159

160
  const handlePinClick = useCallback((result: Result<T>) => {
18✔
161
    setSelectedResult(prev => prev === result ? undefined : result)
6!
162
  }, [])
163

164
  useEffect(() => {
12✔
165
    scrollToResult?.(selectedResult);
16!
166
  }, [selectedResult])
167

168
  useEffect(() => {
18✔
169
    if (mapContainer.current) {
18!
170
      if (map.current && allowUpdates) {
18!
171
        // Update to existing Map
172
        handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
173
      } else if (!map.current && mapboxInstance) {
18✔
174
        const options: mapboxgl.MapboxOptions = {
16✔
175
          container: mapContainer.current,
176
          style: 'mapbox://styles/mapbox/streets-v11',
177
          center: [-74.005371, 40.741611],
178
          zoom: 9,
179
          ...mapboxOptions
180
        };
181
        map.current = new mapboxInstance.Map(options);
16✔
182
        const mapbox = map.current;
16✔
183
        mapbox.resize();
16✔
184
        const nav = new mapboxInstance.NavigationControl({
16✔
185
          showCompass: false,
186
          showZoom: true,
187
          visualizePitch: false
188
        });
189
        mapbox.addControl(nav, 'top-right');
16✔
190
        if (onDragDebounced) {
16!
191
          mapbox.on('drag', () => {
11✔
192
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
193
          });
194
          mapbox.on('zoom', (e) => {
11✔
195
            if (e.originalEvent) {
180!
196
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
NEW
197
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
198
            }
199
          });
200
        }
201
      }
202
    }
203
  }, [mapboxOptions, onDragDebounced]);
204

205
  useEffect(() => {
18✔
206
    if (iframeWindow && map.current) {
18!
NEW
207
      map.current.resize();
×
208
    }
209
  }, [mapContainer.current]);
210

211
  useEffect(() => {
18✔
212
    markers.current.forEach(marker => marker.remove());
18✔
213
    markers.current = [];
18✔
214
    const mapbox = map.current;
18✔
215
    if (mapbox && locationResults?.length > 0) {
18!
216
      const bounds = new mapboxInstance.LngLatBounds();
17✔
217
      locationResults.forEach((result, i) => {
17✔
218
        const markerLocation = getCoordinate(result);
23✔
219
        if (markerLocation) {
23!
220
          const { latitude, longitude } = markerLocation;
21✔
221
          const el = document.createElement('div');
21✔
222
          const markerOptions: mapboxgl.MarkerOptions = {};
21✔
223
          if (PinComponent) {
21✔
224
            if (renderPin) {
5✔
225
              console.warn(
1✔
226
                'Found both PinComponent and renderPin props. Using PinComponent.'
227
              );
228
            }
229
            ReactDOM.render(<PinComponent
5✔
230
              index={i}
231
              mapbox={mapbox}
232
              result={result}
233
              selected={selectedResult === result}
234
              onClick={handlePinClick}
235
            />, el);
236
            markerOptions.element = el;
5✔
237
          } else if (renderPin) {
16✔
238
            renderPin({ index: i, mapbox, result, container: el });
1✔
239
            markerOptions.element = el;
1✔
240
          } else if (pinColor) {
15!
NEW
241
            markerOptions.color = pinColor;
×
242
          }
243
          const marker = new mapboxInstance.Marker(markerOptions)
21✔
244
            .setLngLat({ lat: latitude, lng: longitude })
245
            .addTo(mapbox);
246
          markers.current.push(marker);
21✔
247
          bounds.extend([longitude, latitude]);
21✔
248
        }
249
      });
250

251
      if (!bounds.isEmpty()){
17!
252
        mapbox.fitBounds(bounds, {
17✔
253
          padding: { top: 50, bottom: 50, left: 50, right: 50 },
254
          maxZoom: mapboxOptions?.maxZoom ?? 15
155✔
255
        });
256
      }
257
    }
258
  }, [PinComponent, getCoordinate, locationResults, selectedResult, pinColor]);
259

260
  return (
18✔
261
    <div ref={mapContainer} className='h-full w-full' />
262
  );
263
}
264

265
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
266
  if (mapboxOptions?.style) {
×
267
    currentMap.setStyle(mapboxOptions.style);
×
268
  }
269
  // Add more options to update as needed
270
}
271

272
function isCoordinate(data: unknown): data is Coordinate {
273
  return typeof data == 'object'
21✔
274
    && typeof data?.['latitude'] === 'number'
81✔
275
    && typeof data?.['longitude'] === 'number';
78✔
276
}
277

278
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
279
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
22✔
280
  if (!yextDisplayCoordinate) {
22✔
281
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
282
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
283
    return undefined;
1✔
284
  }
285
  if (!isCoordinate(yextDisplayCoordinate)) {
21✔
286
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
287
    return undefined;
1✔
288
  }
289
  return yextDisplayCoordinate;
20✔
290
}
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