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

yext / search-ui-react / 16125266659

07 Jul 2025 06:46PM UTC coverage: 88.338% (+0.1%) from 88.189%
16125266659

push

github

web-flow
Use react-i18next for default translations (#543)

* Use react-i18next for default translations

The default translations live in the locales/ folder.
To enable translation on the react components, use <SearchI18nextProvider/> wrapper.
SearchI18nextProvider component accepts a seach headless instance and an optional translation overrides object.

J=WAT-3442,WAT-3443
TEST=manual

updated test-site to enable translation with some overrides. Spun up test-site and saw expected translations and overrides on initial load and upon locale changes.

* Removed fallbacks.

Manually tested on test-site and VLE locator.
1. Tested without SearchI18nextProvider wrapper, saw the strings rendered in English regardless of the locale.
2. Tested with SearchI18nextProvider wrapper, saw strings translated based on locale files and overrides are applied as expected.

Updated jest tests.

820 of 1025 branches covered (80.0%)

Branch coverage included in aggregate %.

96 of 100 new or added lines in 28 files covered. (96.0%)

1 existing line in 1 file now uncovered.

2066 of 2242 relevant lines covered (92.15%)

136.4 hits per line

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

88.72
/src/components/SearchBar.tsx
1
import { useTranslation } from 'react-i18next';
6✔
2
import {
6✔
3
  SearchHeadless,
4
  QuerySource,
5
  SearchTypeEnum,
6
  UniversalLimit,
7
  useSearchActions,
8
  useSearchState,
9
  useSearchUtilities,
10
  VerticalResults as VerticalResultsData
11
} from '@yext/search-headless-react';
12
import classNames from 'classnames';
6✔
13
import React, { Fragment, isValidElement, PropsWithChildren, ReactNode, useCallback, useEffect, useMemo } from 'react';
6✔
14
import { useEntityPreviews } from '../hooks/useEntityPreviews';
6✔
15
import { useRecentSearches } from '../hooks/useRecentSearches';
6✔
16
import { useSearchWithNearMeHandling } from '../hooks/useSearchWithNearMeHandling';
6✔
17
import { useSynchronizedRequest } from '../hooks/useSynchronizedRequest';
6✔
18
import { useDebouncedFunction } from '../hooks/useDebouncedFunction';
6✔
19
import { VerticalDividerIcon } from '../icons/VerticalDividerIcon';
6✔
20
import { HistoryIcon as RecentSearchIcon } from '../icons/HistoryIcon';
6✔
21
import { CloseIcon } from '../icons/CloseIcon';
6✔
22
import { MagnifyingGlassIcon } from '../icons/MagnifyingGlassIcon';
6✔
23
import { Dropdown } from './Dropdown/Dropdown';
6✔
24
import { useDropdownContext } from './Dropdown/DropdownContext';
6✔
25
import { DropdownInput } from './Dropdown/DropdownInput';
6✔
26
import { DropdownItem } from './Dropdown/DropdownItem';
6✔
27
import { DropdownMenu } from './Dropdown/DropdownMenu';
6✔
28
import { FocusedItemData } from './Dropdown/FocusContext';
29
import { useComposedCssClasses, twMerge } from '../hooks/useComposedCssClasses';
6✔
30
import { SearchButton } from './SearchButton';
6✔
31
import {
6✔
32
  renderAutocompleteResult,
33
  AutocompleteResultCssClasses,
34
  builtInCssClasses as AutocompleteResultBuiltInCssClasses
35
} from './utils/renderAutocompleteResult';
36
import { useSearchBarAnalytics } from '../hooks/useSearchBarAnalytics';
6✔
37
import { isVerticalLink, VerticalLink } from '../models/verticalLink';
6✔
38
import { executeAutocomplete as executeAutocompleteSearch } from '../utils/search-operations';
6✔
39
import { clearStaticRangeFilters } from '../utils/filterutils';
6✔
40
import { recursivelyMapChildren } from './utils/recursivelyMapChildren';
6✔
41

42
const builtInCssClasses: Readonly<SearchBarCssClasses> = {
71✔
43
  searchBarContainer: 'h-12 mb-6',
44
  inputDivider: 'border-t border-gray-200 mx-2.5',
45
  inputElement: 'outline-none flex-grow border-none h-11 pl-5 pr-2 text-neutral-dark text-base placeholder:text-neutral-light',
46
  searchButtonContainer: ' w-8 h-full mx-2 flex flex-col justify-center items-center',
47
  searchButton: 'h-7 w-7',
48
  focusedOption: 'bg-gray-100',
49
  clearButton: 'h-3 w-3 mr-3.5',
50
  verticalDivider: 'mr-0.5',
51
  recentSearchesIcon: 'w-5 mr-1 flex-shrink-0 h-full text-gray-400',
52
  recentSearchesOption: 'whitespace-no-wrap max-w-full px-3 text-neutral-dark truncate',
53
  recentSearchesNonHighlighted: 'font-normal', // Swap this to semibold once we apply highlighting to recent searches
54
  verticalLink: 'ml-12 pl-1 text-neutral italic',
55
  entityPreviewsDivider: 'h-px bg-gray-200 mt-1 mb-4 mx-3.5',
56
  ...AutocompleteResultBuiltInCssClasses
57
};
58

59
/**
60
 * The CSS class interface for the {@link SearchBar}.
61
 *
62
 * @public
63
 */
64
export interface SearchBarCssClasses extends AutocompleteResultCssClasses {
65
  searchBarContainer?: string,
66
  inputElement?: string,
67
  inputDivider?: string,
68
  clearButton?: string,
69
  searchButton?: string,
70
  searchButtonContainer?: string,
71
  focusedOption?: string,
72
  recentSearchesIcon?: string,
73
  recentSearchesOption?: string,
74
  recentSearchesNonHighlighted?: string,
75
  verticalLink?: string,
76
  verticalDivider?: string,
77
  entityPreviewsDivider?: string
78
}
79

80
/**
81
 * The type of a functional React component which renders entity previews using
82
 * a map of vertical key to the corresponding VerticalResults data.
83
 *
84
 * @remarks
85
 * The autocomplete loading state is passed in as an optional param.
86
 *
87
 * Default props for rendering corresponding DropdownItems are passed in:
88
 * an onClick function to allow an entity preview to be submitted, and
89
 * an ariaLabel function that returns text for the screenreader
90
 *
91
 * For the entity previews to be navigable in the search bar's dropdown section,
92
 * wrap each entity preview in a {@link DropdownItem} component.
93
 *
94
 * @public
95
 */
96
export type RenderEntityPreviews = (
97
  autocompleteLoading: boolean,
98
  verticalKeyToResults: Record<string, VerticalResultsData>,
99
  dropdownItemProps: {
100
    onClick: (value: string, _index: number, itemData?: FocusedItemData) => void,
101
    ariaLabel: (value: string) => string
102
  }
103
) => JSX.Element | null;
104

105
/**
106
 * The configuration options for Visual Autocomplete.
107
 *
108
 * @public
109
 */
110
export interface VisualAutocompleteConfig {
111
  /** The Search Headless instance used to perform visual autocomplete searches. */
112
  entityPreviewSearcher: SearchHeadless,
113
  /** Renders entity previews based on the autocomplete loading state and results. */
114
  renderEntityPreviews: RenderEntityPreviews,
115
  /** Specify which verticals to include for VisualAutocomplete. */
116
  includedVerticals: string[],
117
  /** Specify the number of entities to return per vertical. **/
118
  universalLimit?: UniversalLimit,
119
  /** The debouncing time, in milliseconds, for making API requests for entity previews. */
120
  entityPreviewsDebouncingTime?: number
121
}
122

123
/**
124
 * The interface of a function which is called on a search.
125
 *
126
 * @public
127
 */
128
export type onSearchFunc = (searchEventData: { verticalKey?: string, query?: string }) => void;
129

130
/**
131
 * The props for the {@link SearchBar} component.
132
 *
133
 * @public
134
 */
135
export interface SearchBarProps {
136
  /** The search bar's placeholder text. */
137
  placeholder?: string,
138
  /** {@inheritDoc LocationBiasProps.geolocationOptions} */
139
  geolocationOptions?: PositionOptions,
140
  /** CSS classes for customizing the component styling. */
141
  customCssClasses?: SearchBarCssClasses,
142
  /** {@inheritDoc VisualAutocompleteConfig} */
143
  visualAutocompleteConfig?: VisualAutocompleteConfig,
144
  /** Shows vertical links if true, set to false on default. */
145
  showVerticalLinks?: boolean,
146
  /** A function which is called when a vertical link is selected. */
147
  onSelectVerticalLink?: (data: { verticalLink: VerticalLink, querySource: QuerySource }) => void,
148
  /** A function which returns a display label for the given verticalKey. */
149
  verticalKeyToLabel?: (verticalKey: string) => string,
150
  /** Hides recent searches if true. */
151
  hideRecentSearches?: boolean,
152
  /** Limits the number of recent searches shown. */
153
  recentSearchesLimit?: number,
154
  /** A callback which is called when a search is ran. */
155
  onSearch?: onSearchFunc
156
}
157

158
/**
159
 * Renders a SearchBar that is hooked up with an InputDropdown component.
160
 *
161
 * @public
162
 */
163
export function SearchBar({
6✔
164
  placeholder,
165
  geolocationOptions,
166
  hideRecentSearches,
167
  visualAutocompleteConfig,
168
  showVerticalLinks = false,
309✔
169
  onSelectVerticalLink,
170
  verticalKeyToLabel,
171
  recentSearchesLimit = 5,
341✔
172
  customCssClasses,
173
  onSearch
174
}: SearchBarProps): JSX.Element {
175
  const { t } = useTranslation();
359✔
176
  const {
177
    entityPreviewSearcher,
178
    renderEntityPreviews,
179
    includedVerticals,
180
    universalLimit,
181
    entityPreviewsDebouncingTime = 500
359✔
182
  } = visualAutocompleteConfig ?? {};
359✔
183
  const searchActions = useSearchActions();
359✔
184
  const searchUtilities = useSearchUtilities();
359✔
185
  const reportAnalyticsEvent = useSearchBarAnalytics();
359✔
186

187
  const query = useSearchState(state => state.query.input) ?? '';
771✔
188
  const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses);
359✔
189
  const isVertical = useSearchState(state => state.meta.searchType) === SearchTypeEnum.Vertical;
771✔
190
  const verticalKey = useSearchState(state => state.vertical.verticalKey);
771✔
191
  const debouncedExecuteAutocompleteSearch = useDebouncedFunction( () => executeAutocompleteSearch(searchActions), 200);
359✔
192
  const [autocompleteResponse, executeAutocomplete, clearAutocompleteData] = useSynchronizedRequest(
229✔
193
    async () => {
194
      return debouncedExecuteAutocompleteSearch ?
270!
195
        debouncedExecuteAutocompleteSearch() :
196
        undefined;
197
    }
198
  );
199
  const [
200
    executeQueryWithNearMeHandling,
201
    autocompletePromiseRef,
202
  ] = useSearchWithNearMeHandling(geolocationOptions, onSearch);
359✔
203
  const [
204
    recentSearches,
205
    setRecentSearch,
206
    clearRecentSearches,
207
  ] = useRecentSearches(recentSearchesLimit, verticalKey);
359✔
208
  const filteredRecentSearches = recentSearches?.filter(search =>
229✔
209
    searchUtilities.isCloseMatch(search.query, query)
325✔
210
  );
211

212
  useEffect(() => {
359✔
213
    if (hideRecentSearches) {
31✔
214
      clearRecentSearches();
5✔
215
    }
216
  }, [clearRecentSearches, hideRecentSearches]);
217

218
  const clearAutocomplete = useCallback(() => {
359✔
219
    clearAutocompleteData();
27✔
220
    autocompletePromiseRef.current = undefined;
27✔
221
  }, [autocompletePromiseRef, clearAutocompleteData]);
222

223
  const executeQuery = useCallback(() => {
359✔
224
    if (!hideRecentSearches) {
24✔
225
      const input = searchActions.state.query.input;
21✔
226
      input && setRecentSearch(input);
21✔
227
    }
228
    executeQueryWithNearMeHandling();
24✔
229
  }, [
230
    searchActions.state.query.input,
231
    executeQueryWithNearMeHandling,
232
    hideRecentSearches,
233
    setRecentSearch
234
  ]);
235

236
  const handleSubmit = useCallback((value?: string, index?: number, itemData?: FocusedItemData) => {
359✔
237
    value !== undefined && searchActions.setQuery(value);
25✔
238
    searchActions.setOffset(0);
25✔
239
    searchActions.setFacets([]);
25✔
240
    clearStaticRangeFilters(searchActions);
25✔
241
    if (itemData && isVerticalLink(itemData.verticalLink) && onSelectVerticalLink) {
25✔
242
      onSelectVerticalLink({ verticalLink: itemData.verticalLink, querySource: QuerySource.Autocomplete });
1✔
243
    } else {
244
      executeQuery();
24✔
245
    }
246
    if (typeof index === 'number' && index >= 0 && !itemData?.isEntityPreview) {
25✔
247
      reportAnalyticsEvent('AUTO_COMPLETE_SELECTION', value);
3✔
248
    }
249
  }, [searchActions, executeQuery, onSelectVerticalLink, reportAnalyticsEvent]);
250

251
  const [
252
    entityPreviewsState,
253
    executeEntityPreviewsQuery
254
  ] = useEntityPreviews(entityPreviewSearcher, entityPreviewsDebouncingTime);
359✔
255
  const { verticalKeyToResults, isLoading: entityPreviewsLoading } = entityPreviewsState;
359✔
256
  const entityPreviews = renderEntityPreviews?.(
229✔
257
    entityPreviewsLoading,
258
    verticalKeyToResults,
259
    { onClick: handleSubmit, ariaLabel: getAriaLabel }
260
  );
261
  const updateEntityPreviews = useCallback((query: string) => {
359✔
262
    if (!renderEntityPreviews || !includedVerticals) {
272!
263
      return;
272✔
264
    }
265
    executeEntityPreviewsQuery(query, universalLimit ?? {}, includedVerticals);
×
266
  }, [executeEntityPreviewsQuery, renderEntityPreviews, includedVerticals, universalLimit]);
267

268
  const handleInputFocus = useCallback((value = '') => {
359!
269
    searchActions.setQuery(value);
40✔
270
    updateEntityPreviews(value);
40✔
271
    autocompletePromiseRef.current = executeAutocomplete();
40✔
272
  }, [searchActions, autocompletePromiseRef, executeAutocomplete, updateEntityPreviews]);
273

274
  const handleInputChange = useCallback((value = '') => {
359!
275
    searchActions.setQuery(value);
230✔
276
    updateEntityPreviews(value);
230✔
277
    autocompletePromiseRef.current = executeAutocomplete();
230✔
278
  }, [searchActions, autocompletePromiseRef, executeAutocomplete, updateEntityPreviews]);
279

280
  const handleClickClearButton = useCallback(() => {
359✔
281
    updateEntityPreviews('');
2✔
282
    searchActions.setQuery('');
2✔
283
    reportAnalyticsEvent('SEARCH_CLEAR_BUTTON');
2✔
284
  }, [handleSubmit, reportAnalyticsEvent, updateEntityPreviews]);
285

286
  function renderInput() {
287
    return (
359✔
288
      <DropdownInput
289
        className={cssClasses.inputElement}
290
        placeholder={placeholder}
291
        onSubmit={handleSubmit}
292
        onFocus={handleInputFocus}
293
        onChange={handleInputChange}
294
        ariaLabel={t('conductASearch')}
295
      />
296
    );
297
  }
298

299
  function renderRecentSearches() {
300
    const recentSearchesCssClasses = {
201✔
301
      icon: cssClasses.recentSearchesIcon,
302
      option: cssClasses.recentSearchesOption,
303
      nonHighlighted: cssClasses.recentSearchesNonHighlighted
304
    };
305

306
    return filteredRecentSearches?.map((result, i) => (
201✔
307
      <DropdownItem
317✔
308
        className='flex items-center h-6.5 px-3.5 py-1.5 cursor-pointer hover:bg-gray-100'
309
        focusedClassName={twMerge('flex items-center h-6.5 px-3.5 py-1.5 cursor-pointer hover:bg-gray-100', cssClasses.focusedOption)}
310
        key={i}
311
        value={result.query}
312
        onClick={handleSubmit}
313
      >
314
        {renderAutocompleteResult(
315
          { value: result.query, inputIntents: [] },
316
          recentSearchesCssClasses,
317
          RecentSearchIcon,
318
          t('recentSearch', {
319
            query: result.query
320
          })
321
        )}
322
      </DropdownItem>
323
    ));
324
  }
325

326
  const itemDataMatrix = useMemo(() => {
359✔
327
    return autocompleteResponse?.results.map(result => {
52✔
328
      return result.verticalKeys?.map(verticalKey => ({
23✔
329
        verticalLink: { verticalKey, query: result.value }
330
      })) ?? [];
331
    }) ?? [];
332
  }, [autocompleteResponse?.results]);
333

334
  function renderQuerySuggestions() {
335
    return autocompleteResponse?.results.map((result, i) => (
201✔
336
      <Fragment key={i}>
44✔
337
        <DropdownItem
338
          className='flex items-stretch py-1.5 px-3.5 cursor-pointer hover:bg-gray-100'
339
          focusedClassName={twMerge('flex items-stretch py-1.5 px-3.5 cursor-pointer hover:bg-gray-100', cssClasses.focusedOption)}
340
          value={result.value}
341
          onClick={handleSubmit}
342
        >
343
          {renderAutocompleteResult(
344
            result,
345
            cssClasses,
346
            MagnifyingGlassIcon,
347
            t('autocompleteSuggestion', { suggestion: result.value })
348
          )}
349
        </DropdownItem>
350
        {showVerticalLinks && !isVertical && result.verticalKeys?.map((verticalKey, j) => (
36✔
351
          <DropdownItem
36✔
352
            key={j}
353
            className='flex items-stretch py-1.5 px-3.5 cursor-pointer hover:bg-gray-100'
354
            focusedClassName={twMerge('flex items-stretch py-1.5 px-3.5 cursor-pointer hover:bg-gray-100', cssClasses.focusedOption)}
355
            value={result.value}
356
            itemData={itemDataMatrix[i][j]}
357
            onClick={handleSubmit}
358
          >
359
            {renderAutocompleteResult(
360
              {
361
                value: `in ${verticalKeyToLabel ? verticalKeyToLabel(verticalKey) : verticalKey}`,
8✔
362
                inputIntents: []
363
              },
364
              { ...cssClasses, option: cssClasses.verticalLink }
365
            )}
366
          </DropdownItem>
367
        ))}
368
      </Fragment>
369
    ));
370
  }
371

372
  function renderClearButton() {
373
    return (
251✔
374
      <>
375
        <button
376
          aria-label={t('clearTheSearchBar')}
377
          className={cssClasses.clearButton}
378
          onClick={handleClickClearButton}
379
        >
380
          <CloseIcon />
381
        </button>
382
        <VerticalDividerIcon className={cssClasses.verticalDivider} />
383
      </>
384
    );
385
  }
386

387
  const entityPreviewsCount = calculateEntityPreviewsCount(entityPreviews);
359✔
388
  const showEntityPreviewsDivider = entityPreviews
359!
389
    && !!(autocompleteResponse?.results.length || filteredRecentSearches?.length);
×
390
  const hasItems = !!(autocompleteResponse?.results.length
359✔
391
    || filteredRecentSearches?.length || entityPreviews);
392
  const screenReaderText = getScreenReaderText(
229✔
393
    autocompleteResponse?.results.length,
394
    filteredRecentSearches?.length,
395
    entityPreviewsCount
396
  );
397
  const activeClassName = classNames('relative z-10 bg-white border rounded-3xl border-gray-200 w-full overflow-hidden', {
359✔
398
    ['shadow-lg']: hasItems
399
  });
400

401
  const handleToggleDropdown = useCallback((isActive) => {
359✔
402
    if (!isActive) {
297✔
403
      clearAutocomplete();
27✔
404
    }
405
  }, [clearAutocomplete]);
406

407
  return (
359✔
408
    <div className={cssClasses.searchBarContainer}>
409
      <Dropdown
410
        className='relative bg-white border rounded-3xl border-gray-200 w-full overflow-hidden'
411
        activeClassName={activeClassName}
412
        screenReaderText={screenReaderText}
413
        parentQuery={query}
414
        onToggle={handleToggleDropdown}
415
      >
416
        <div className='inline-flex items-center justify-between w-full'>
417
          {renderInput()}
418
          {query && renderClearButton()}
610✔
419
          <DropdownSearchButton
420
            handleSubmit={handleSubmit}
421
            cssClasses={cssClasses}
422
          />
423
        </div>
424
        {hasItems &&
560✔
425
          <StyledDropdownMenu cssClasses={cssClasses}>
426
            {renderRecentSearches()}
427
            {renderQuerySuggestions()}
428
            {showEntityPreviewsDivider && <div className={cssClasses.entityPreviewsDivider}></div>}
201!
429
            {entityPreviews}
430
          </StyledDropdownMenu>
431
        }
432
      </Dropdown>
433
    </div>
434
  );
435
}
436

437
function StyledDropdownMenu({ cssClasses, children }: PropsWithChildren<{
438
  cssClasses: {
439
    inputDivider?: string
440
  }
441
}>) {
442
  return (
181✔
443
    <DropdownMenu>
444
      <div className={cssClasses.inputDivider} />
445
      <div className='bg-white py-4'>
446
        {children}
447
      </div>
448
    </DropdownMenu>
449
  );
450
}
451

452
function getScreenReaderText(
453
  autocompleteOptions = 0,
340✔
454
  recentSearchesOptions = 0,
×
455
  entityPreviewsCount = 0
×
456
): string {
457
  const { t } = useTranslation();
359✔
458
  let texts: string[] = [];
359✔
459
  recentSearchesOptions > 0 && texts.push(t('recentSearchesFound', {
359✔
460
    count: recentSearchesOptions
461
  }));
462
  entityPreviewsCount > 0  && texts.push(t('resultPreviewsFound', {
359!
463
    count: entityPreviewsCount
464
  }));
465
  autocompleteOptions > 0 && texts.push(t('autocompleteSuggestionsFound', {
359✔
466
    count: autocompleteOptions
467
  }));
468

469
  const text = texts.join(' ');
359✔
470
  if (text === '') {
359✔
471
    return t('noAutocompleteSuggestionsFound')
192✔
472
  }
473
  return text.trim();
201✔
474
}
475

476
function DropdownSearchButton({ handleSubmit, cssClasses }: {
477
  handleSubmit: () => void,
478
  cssClasses: {
479
    searchButtonContainer?: string,
480
    searchButton?: string
481
  }
482
}) {
483
  const { toggleDropdown } = useDropdownContext();
407✔
484
  const handleClick = useCallback(() => {
407✔
485
    handleSubmit();
4✔
486
    toggleDropdown(false);
4✔
487
  }, [handleSubmit, toggleDropdown]);
488
  return (
407✔
489
    <div className={cssClasses.searchButtonContainer}>
490
      <SearchButton
491
        className={cssClasses.searchButton}
492
        handleClick={handleClick}
493
      />
494
    </div>
495
  );
496
}
497

498
function getAriaLabel(value: string): string {
NEW
499
  const { t } = useTranslation();
×
NEW
500
  return t('resultPreview', { value })
×
501
}
502

503
/**
504
 * Calculates the number of navigable entity previews from a ReactNode containing DropdownItems.
505
 */
506
export function calculateEntityPreviewsCount(children: ReactNode): number {
6✔
507
  let count = 0;
359✔
508
  recursivelyMapChildren(children, c => {
359✔
509
    if (isValidElement(c) && c.type === DropdownItem) {
×
510
      count++;
×
511
    }
512
    return c;
×
513
  });
514
  return count;
359✔
515
}
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