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>