/ shared / components / src / components / SearchInput / SearchInput.svelte
SearchInput.svelte
  1  <script lang="ts">
  2      import { createEventDispatcher } from 'svelte';
  3      import type { Writable } from 'svelte/store';
  4      import type { NavigationId } from '@amp/web-app-components/src/types';
  5      import clickOutside from '@amp/web-app-components/src/actions/click-outside';
  6      import SearchSuggestions from '@amp/web-app-components/src/components/SearchSuggestions/SearchSuggestions.svelte';
  7      import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types';
  8      import {
  9          ClearEventLocation,
 10          SEARCH_EVENTS,
 11      } from '@amp/web-app-components/src/constants';
 12      import { getUpdatedFocusedIndex } from '@amp/web-app-components/src/utils/getUpdatedFocusedIndex';
 13      import { debounce } from '@amp/web-app-components/src/utils/debounce';
 14      import type {
 15          HighlightedSearchSuggestion,
 16          SearchSuggestion,
 17      } from '@amp/web-app-components/src/utils/processTextSearchSuggestion';
 18      import SearchIcon from '@amp/web-app-components/assets/icons/search.svg';
 19  
 20      const {
 21          SEARCH_INPUT_HAS_FOCUS,
 22          MAKE_SEARCH_QUERY_FROM_SUGGESTION,
 23          MAKE_SEARCH_QUERY_FROM_INPUT,
 24          CLICKED_OUTSIDE_SUGGESTIONS,
 25          CLICKED_OUTSIDE,
 26          RESET_SEARCH_INPUT,
 27          MENU_ITEM_CLICK,
 28          SHOW_SEARCH_SUGGESTIONS,
 29      } = SEARCH_EVENTS;
 30  
 31      $: debouncedHandleSearchInput = debounce(handleSearchInput, 100);
 32  
 33      /**
 34       * The translate fn to be used to handle localization
 35       * @type {function}
 36       */
 37      export let translateFn: (key: string) => string;
 38  
 39      /**
 40       * The handler to be executed that retrieves suggestions for a given term
 41       * @type {function}
 42       */
 43      export let getSuggestionsForPartialTerm: (
 44          partialTerm: string,
 45      ) => Promise<SearchSuggestion[]> = async () => [];
 46  
 47      /**
 48       * The store containing the currently selected tab.
 49       */
 50      export let currentTab: Writable<NavigationId | null>;
 51  
 52      /**
 53       * The pre-filled value of the text field
 54       */
 55      export let defaultValue: string | null = null;
 56  
 57      /**
 58       * The menu item that should be selected when a search is performed or the
 59       * search field receives focus while not on this item.
 60       */
 61      export let menuItem: NavigationItem;
 62  
 63      /**
 64       * Optional argument to disable search suggestions completely
 65       */
 66      export let hideSuggestions = false;
 67  
 68      let suggestions = [];
 69      let cachedSuggestions = [];
 70      let partialTerm = !!defaultValue ? defaultValue : '';
 71      let focusedSearchSuggestionIndex = null;
 72      let searchInputElement: HTMLInputElement;
 73      let showSuggestion = false;
 74      let showCancelButton = false;
 75  
 76      $: showSuggestion = suggestions?.length > 0;
 77      $: handleShowSuggestion(showSuggestion);
 78  
 79      const dispatch = createEventDispatcher<{
 80          resetSearchInput: null; // no details returned
 81          menuItemClick: NavigationItem;
 82          searchInputHasFocus: null; // no details returned
 83          makeSearchQueryFromInput: { term: string };
 84          // Unfortunately SearchSuggestions uses Array<any> so no way to fully type this.
 85          // rdar://137049269 ((Shared/Components) Create Types for SearchSuggestions component)
 86          makeSearchQueryFromSuggestion: { suggestion: any };
 87          clickedOutsideSuggestions: null; // no details returned
 88          clickedOutside: null; // no details returned
 89          clear: { from: ClearEventLocation };
 90          showSearchSuggestions: { showSearchSuggestions: boolean };
 91      }>();
 92  
 93      function resetSearchInputState() {
 94          searchInputElement.value = '';
 95          partialTerm = '';
 96          suggestions = [];
 97          cachedSuggestions = [];
 98          focusedSearchSuggestionIndex = null;
 99          dispatch(RESET_SEARCH_INPUT);
100      }
101  
102      /**
103       * We use a click focus here (instead of input focus) as a
104       * lighter touch way to detect interaction with the search input.
105       *
106       * See additional explanation here:
107       *   rdar://83511986 (JMOTW AX Music: Focussing on Search Field should not trigger a Context Change in Routing)
108       */
109      function handleSearchInputClickFocus() {
110          showCancelButton = true;
111          const currentTerm = searchInputElement.value;
112          if (currentTerm === partialTerm && cachedSuggestions.length > 0) {
113              suggestions = cachedSuggestions;
114              cachedSuggestions = [];
115          }
116  
117          // Only switch to the search tab if we aren't already on it
118          if ($currentTab !== menuItem.id) {
119              currentTab.set(menuItem.id);
120              dispatch(MENU_ITEM_CLICK, menuItem);
121          }
122  
123          dispatch(SEARCH_INPUT_HAS_FOCUS);
124      }
125  
126      function handleSearchInputSubmit(event: SubmitEvent) {
127          const term = searchInputElement.value;
128          event.preventDefault();
129  
130          if (term) {
131              dispatch(MAKE_SEARCH_QUERY_FROM_INPUT, {
132                  term,
133              });
134  
135              // Submitting a search always goes to the search tab
136              currentTab.set(menuItem.id);
137  
138              // Cache the current list of suggestions in case searchInputElement
139              // becomes focused again.
140              cachedSuggestions = suggestions;
141              suggestions = [];
142              focusedSearchSuggestionIndex = null;
143  
144              // Also hides the suggestions if visible
145              searchInputElement.blur();
146          }
147      }
148  
149      function onSearchSuggestionChosen(suggestion: HighlightedSearchSuggestion) {
150          dispatch(MAKE_SEARCH_QUERY_FROM_SUGGESTION, { suggestion });
151  
152          // Clicking on a search suggestion always goes to the search tab
153          currentTab.set(menuItem.id);
154  
155          resetSearchInputState();
156          searchInputElement.value = suggestion.displayTerm;
157      }
158  
159      function onSearchSuggestionFocused(index: number) {
160          focusedSearchSuggestionIndex = index;
161      }
162  
163      function containerHandleKeyDown(event: KeyboardEvent) {
164          switch (event.key) {
165              case 'ArrowDown':
166              case 'ArrowUp':
167                  event.preventDefault();
168                  break;
169          }
170      }
171  
172      function containerHandleKeyUp(event: KeyboardEvent) {
173          switch (event.key) {
174              case 'ArrowDown':
175                  focusedSearchSuggestionIndex = getUpdatedFocusedIndex(
176                      1,
177                      focusedSearchSuggestionIndex,
178                      suggestions.length,
179                  );
180                  break;
181  
182              case 'ArrowUp':
183                  focusedSearchSuggestionIndex = getUpdatedFocusedIndex(
184                      -1,
185                      focusedSearchSuggestionIndex,
186                      suggestions.length,
187                  );
188                  break;
189  
190              case 'Escape':
191                  resetSearchInputState();
192                  break;
193  
194              case 'Tab':
195              case 'Control':
196              case 'Alt':
197              case 'Meta':
198              case 'Shift':
199              case ' ': // Spacebar
200                  // Don't do anything for remaining navigation keys.
201                  break;
202  
203              default:
204                  // If this event is not a navigational key, or not a Tab the focus is returned to the input
205                  // allowing the user to type with the this key stroke. This is necesasry because
206                  // VoiceOver first lands on the container and not on the input field.
207                  searchInputElement.focus();
208          }
209  
210          event.preventDefault();
211      }
212  
213      async function handleSearchInput(input: HTMLInputElement) {
214          const searchInput = input ?? searchInputElement;
215          partialTerm = searchInput.value;
216  
217          if (!partialTerm) {
218              suggestions = [];
219              return;
220          }
221  
222          let _suggestions = await getSuggestionsForPartialTerm(partialTerm);
223          cachedSuggestions = _suggestions;
224  
225          // rdar://93009223 (JMOTW: Hitting enter in search field before suggestions loads leaves suggestions stuck)
226          //
227          // We only want to show suggestions here if the input is focused.
228          // Without this condition, suggestions will show up after enter is pressed if
229          // it takes too long for the api to return
230          if (document.activeElement === searchInput) {
231              suggestions = _suggestions;
232              cachedSuggestions = [];
233          }
234      }
235  
236      /**
237       * We don't want `menuItemClick` to also get debounced
238       * Extrapolating logic here to handle the route switch as well as the input delay
239       *
240       * rdar://83511986 (AX Music: Focussing on Search Field should not trigger a Context Change in Routing)
241       *
242       * TODO: we currently have no way to re-render the search landing page if the currently selected tab
243       * is already on the search tab. The best solution (as of now) to re-render the search landing page
244       * is to check if the input value is empty.
245       *
246       * rdar://91073241 (JMOTW: Search - Find a way to stop re-renders of search landing page)
247       */
248      function handleSearchInputActivity(e: Event) {
249          if (
250              !(e instanceof InputEvent) &&
251              (e.target as HTMLInputElement).value === ''
252          ) {
253              dispatch('clear', { from: ClearEventLocation.Input });
254          }
255          const shouldDispatchMenuClick =
256              $currentTab !== menuItem.id || searchInputElement.value === '';
257  
258          // From svelte docs:
259          // The store value gets set to the value of the argument if
260          // the store value is not already equal to it.
261          // https://svelte.dev/docs#run-time-svelte-store-writable
262          currentTab.set(menuItem.id);
263  
264          if (shouldDispatchMenuClick) {
265              menuItem.opaqueData = () => ({ from: 'searchInputClear' });
266              dispatch(MENU_ITEM_CLICK, menuItem);
267          }
268  
269          debouncedHandleSearchInput(e.target as HTMLInputElement);
270      }
271  
272      function handleClickOutside(event: Event) {
273          const element = (event.target as HTMLElement) || null;
274  
275          const eventPath = event.composedPath ? event.composedPath() : [];
276          const didEventHappenInContextMenu = eventPath.some(
277              (item) =>
278                  'nodeName' in item && item.nodeName === 'AMP-CONTEXTUAL-MENU',
279          );
280  
281          // dont close menu if interacting with context menu
282          if (
283              (element && element.nodeName === 'AMP-CONTEXTUAL-MENU') ||
284              didEventHappenInContextMenu
285          ) {
286              return;
287          }
288  
289          if (suggestions.length > 0) {
290              // Cache the current list of suggestions in case searchInputElement
291              // becomes focused again.
292              cachedSuggestions = suggestions;
293  
294              // Clear out the suggestions so the suggestions disappear
295              suggestions = [];
296  
297              dispatch(CLICKED_OUTSIDE_SUGGESTIONS);
298          }
299  
300          showCancelButton = false;
301          dispatch(CLICKED_OUTSIDE);
302      }
303  
304      function handleShowSuggestion(curShowSuggestions: boolean) {
305          dispatch(SHOW_SEARCH_SUGGESTIONS, {
306              showSearchSuggestions: curShowSuggestions,
307          });
308      }
309  
310      function handleCancelButton() {
311          showCancelButton = false;
312          searchInputElement.value = '';
313          dispatch('clear', { from: ClearEventLocation.Cancel });
314      }
315  </script>
316  
317  <div
318      data-testid="amp-search-input"
319      aria-controls="search-suggestions"
320      aria-expanded={suggestions && suggestions.length > 0}
321      aria-haspopup="listbox"
322      aria-owns="search-suggestions"
323      class="search-input-container"
324      tabindex="-1"
325      role={showSuggestion ? 'combobox' : ''}
326      use:clickOutside={handleClickOutside}
327      on:keydown={containerHandleKeyDown}
328      on:keyup={containerHandleKeyUp}
329  >
330      <div class="flex-container">
331          <form
332              role="search"
333              id="search-input-form"
334              on:submit={handleSearchInputSubmit}
335          >
336              <SearchIcon class="search-svg" aria-hidden="true" />
337  
338              <input
339                  value={defaultValue}
340                  aria-activedescendant={Number.isInteger(
341                      focusedSearchSuggestionIndex,
342                  ) && focusedSearchSuggestionIndex >= 0
343                      ? `search-suggestion-${focusedSearchSuggestionIndex}`
344                      : undefined}
345                  aria-autocomplete="list"
346                  aria-multiline="false"
347                  aria-controls="search-suggestions"
348                  placeholder={translateFn('AMP.Shared.SearchInput.Placeholder')}
349                  spellcheck={false}
350                  autocomplete="off"
351                  autocorrect="off"
352                  autocapitalize="off"
353                  type="search"
354                  class="search-input__text-field"
355                  bind:this={searchInputElement}
356                  data-testid="search-input__text-field"
357                  on:input={handleSearchInputActivity}
358                  on:click={handleSearchInputClickFocus}
359              />
360          </form>
361  
362          {#if showCancelButton}
363              <div
364                  class="search-input__cancel-button-container"
365                  data-testid="search-input__cancel-button-container"
366              >
367                  <button
368                      data-testid="search-input__cancel-button"
369                      on:click={handleCancelButton}
370                      aria-label={translateFn('FUSE.Search.Cancel')}
371                  >
372                      {translateFn('FUSE.Search.Cancel')}
373                  </button>
374              </div>
375          {/if}
376      </div>
377  
378      <div data-testid="search-scope-bar"><slot name="searchScopeBar" /></div>
379  
380      <!-- https://github.com/sveltejs/svelte/issues/5604 -->
381      {#if !hideSuggestions && suggestions && suggestions.length > 0}
382          {#if $$slots['suggestion']}
383              <SearchSuggestions
384                  on:suggestionClicked={(e) =>
385                      onSearchSuggestionChosen(e.detail.suggestion)}
386                  on:suggestionFocused={(e) =>
387                      onSearchSuggestionFocused(e.detail.index)}
388                  {suggestions}
389                  focusedSuggestionIndex={focusedSearchSuggestionIndex}
390                  {translateFn}
391              >
392                  <svelte:fragment slot="suggestion" let:suggestion>
393                      <slot name="suggestion" {suggestion} />
394                  </svelte:fragment>
395              </SearchSuggestions>
396          {:else}
397              <SearchSuggestions
398                  on:suggestionClicked={(e) =>
399                      onSearchSuggestionChosen(e.detail.suggestion)}
400                  on:suggestionFocused={(e) =>
401                      onSearchSuggestionFocused(e.detail.index)}
402                  {suggestions}
403                  focusedSuggestionIndex={focusedSearchSuggestionIndex}
404                  {translateFn}
405              />
406          {/if}
407      {/if}
408  </div>
409  
410  <style lang="scss">
411      @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
412      @use '@amp/web-shared-styles/app/core/mixins/focus' as *;
413  
414      $search-input-text-height: 32px;
415      $search-svg-size-hide-sidebar: 12px;
416  
417      .search-input-container {
418          @media (--sidebar-visible) {
419              position: relative;
420              z-index: var(--z-default);
421          }
422  
423          @media (--range-sidebar-hidden-down) {
424              width: 100%;
425          }
426  
427          :global(.search-svg) {
428              width: 16px;
429              height: 16px;
430              top: 10px;
431              bottom: 10px;
432              position: absolute;
433              fill: var(--searchBoxIconFill);
434              inset-inline-start: 10px;
435              z-index: var(--z-default);
436  
437              @media (--sidebar-visible) {
438                  width: $search-svg-size-hide-sidebar;
439                  height: $search-svg-size-hide-sidebar;
440              }
441          }
442  
443          :global(.search-suggestion-svg) {
444              fill: var(--searchBoxIconFill);
445          }
446      }
447  
448      .search-input__text-field {
449          background-color: var(--pageBG);
450          border-radius: 4px;
451          border-style: solid;
452          border-width: 1px;
453          border-color: var(--searchBarBorderColor);
454          color: var(--systemPrimary-vibrant);
455          font-size: 12px;
456          font-weight: 400;
457          height: $search-input-text-height;
458          letter-spacing: 0;
459          line-height: 1.25;
460          padding-top: 6px;
461          padding-bottom: 5px;
462          width: 100%;
463          padding-inline-end: 5px;
464  
465          @media (--range-sidebar-hidden-down) {
466              height: 38px;
467              border-radius: 9px;
468              padding-inline-start: 34px;
469              font: var(--title-3-tall);
470              font-size: 16px;
471          }
472  
473          @media (--sidebar-visible) {
474              padding-inline-start: 28px;
475          }
476      }
477  
478      input::-webkit-search-decoration,
479      input::-webkit-search-results-decoration {
480          appearance: none;
481      }
482  
483      input::placeholder {
484          color: var(--systemTertiary-vibrant);
485  
486          @media (prefers-color-scheme: dark) {
487              color: var(--systemSecondary-vibrant);
488          }
489      }
490  
491      input:focus {
492          @include focus-shadow;
493      }
494  
495      input::-webkit-search-cancel-button {
496          $cancelButtonSize: 14px;
497          appearance: none;
498          background-position: center;
499          background-repeat: no-repeat;
500          background-size: $cancelButtonSize $cancelButtonSize;
501          height: $cancelButtonSize;
502          width: $cancelButtonSize;
503          background-image: url('/assets/icons/sidebar-searchfield-close-on-light.svg');
504  
505          @media (prefers-color-scheme: dark) {
506              background-image: url('/assets/icons/sidebar-searchfield-close-on-dark.svg');
507          }
508      }
509  
510      .search-input__cancel-button-container {
511          align-self: center;
512          color: var(--keyColor);
513          font: var(--title-3-tall);
514          margin-inline-start: 14px;
515  
516          @media (--sidebar-visible) {
517              display: none;
518          }
519      }
520  
521      .flex-container {
522          @media (--range-sidebar-hidden-down) {
523              display: flex;
524  
525              form {
526                  flex-grow: 1;
527              }
528          }
529      }
530  </style>