| | import React, { useState, useRef, RefObject, useEffect, SetStateAction, useMemo } from 'react' |
| | import cx from 'classnames' |
| | import { useRouter } from 'next/router' |
| | import { ActionList, IconButton, Overlay, Spinner, Stack, TextInput, Banner } from '@primer/react' |
| | import { |
| | SearchIcon, |
| | XCircleFillIcon, |
| | CommentIcon, |
| | CopilotIcon, |
| | FileIcon, |
| | ArrowRightIcon, |
| | ArrowLeftIcon, |
| | } from '@primer/octicons-react' |
| | import { focusTrap } from '@primer/behaviors' |
| |
|
| | import { useTranslation } from '@/languages/components/useTranslation' |
| | import { useVersion } from '@/versions/components/useVersion' |
| | import { |
| | AI_SEARCH_CONTEXT, |
| | executeGeneralSearch, |
| | GENERAL_SEARCH_CONTEXT, |
| | } from '../helpers/execute-search-actions' |
| | import { useCombinedSearchResults } from '@/search/components/hooks/useAISearchAutocomplete' |
| | import { AskAIResults } from './AskAIResults' |
| | import { sendEvent, uuidv4 } from '@/events/components/events' |
| | import { EventType } from '@/events/types' |
| | import { ASK_AI_EVENT_GROUP, SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups' |
| | import { useSharedUIContext } from '@/frame/components/context/SharedUIContext' |
| |
|
| | import type { AIReference } from '../types' |
| | import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types' |
| |
|
| | import { sanitizeSearchQuery } from '@/search/lib/sanitize-search-query' |
| |
|
| | import styles from './SearchOverlay.module.scss' |
| |
|
| | type Props = { |
| | searchOverlayOpen: boolean |
| | parentRef: RefObject<HTMLElement> |
| | debug: boolean |
| | onClose: () => void |
| | params: { |
| | 'search-overlay-input': string |
| | 'search-overlay-ask-ai': string |
| | } |
| | updateParams: ( |
| | updates: Partial<{ |
| | 'search-overlay-input': string |
| | 'search-overlay-ask-ai': string |
| | }>, |
| | ) => void |
| | } |
| |
|
| | |
| | export function SearchOverlay({ |
| | searchOverlayOpen, |
| | parentRef, |
| | debug, |
| | onClose, |
| | params, |
| | updateParams, |
| | }: Props) { |
| | const { t } = useTranslation('search') |
| | const { currentVersion } = useVersion() |
| | const router = useRouter() |
| |
|
| | |
| | const urlSearchInputQuery = params['search-overlay-input'] |
| | const isAskAIState = params['search-overlay-ask-ai'] === 'true' |
| |
|
| | const inputRef = useRef<HTMLInputElement>(null) |
| | const suggestionsListHeightRef = useRef<HTMLUListElement>(null) |
| | |
| | const listElementsRef = React.useRef<Array<HTMLLIElement | null>>([]) |
| |
|
| | const [selectedIndex, setSelectedIndex] = useState<number>(-1) |
| | const [aiQuery, setAIQuery] = useState<string>(urlSearchInputQuery) |
| | const [aiSearchError, setAISearchError] = useState<boolean>(false) |
| | const [aiReferences, setAIReferences] = useState<AIReference[]>([] as AIReference[]) |
| | const [aiCouldNotAnswer, setAICouldNotAnswer] = useState<boolean>(false) |
| | const [showSpinner, setShowSpinner] = useState(false) |
| | const [scrollPos, setScrollPos] = useState(0) |
| | const [announcement, setAnnouncement] = useState<string>('') |
| |
|
| | const { hasOpenHeaderNotifications } = useSharedUIContext() |
| |
|
| | |
| | const searchEventGroupId = useRef<string>('') |
| | const overlayRef = useRef<HTMLDivElement>(null) |
| |
|
| | useEffect(() => { |
| | if (searchOverlayOpen && overlayRef.current) { |
| | focusTrap(overlayRef.current, inputRef.current || undefined) |
| | } |
| | }, [searchOverlayOpen]) |
| |
|
| | useEffect(() => { |
| | searchEventGroupId.current = uuidv4() |
| | }, [searchOverlayOpen]) |
| | |
| | const askAIEventGroupId = useRef<string>('') |
| |
|
| | |
| | useEffect(() => { |
| | if (hasOpenHeaderNotifications) { |
| | const handleScroll = () => { |
| | setScrollPos(window.scrollY) |
| | } |
| |
|
| | window.addEventListener('scroll', handleScroll) |
| | return () => window.removeEventListener('scroll', handleScroll) |
| | } |
| | }, [hasOpenHeaderNotifications]) |
| | const overlayTopValue = scrollPos > 72 ? '0px' : `${88 - scrollPos}px !important` |
| |
|
| | const { |
| | autoCompleteOptions, |
| | searchLoading, |
| | setSearchLoading, |
| | searchError: autoCompleteSearchError, |
| | updateAutocompleteResults, |
| | clearAutocompleteResults, |
| | } = useCombinedSearchResults({ |
| | router, |
| | currentVersion, |
| | debug, |
| | }) |
| |
|
| | const { aiAutocompleteOptions, generalSearchResults, totalGeneralSearchResults } = |
| | autoCompleteOptions |
| |
|
| | |
| | useEffect(() => { |
| | let timer: ReturnType<typeof setTimeout> |
| |
|
| | if (autoCompleteSearchError) { |
| | return setShowSpinner(false) |
| | } |
| |
|
| | |
| | if (!aiAutocompleteOptions.length && !generalSearchResults.length && searchLoading) { |
| | return setShowSpinner(true) |
| | } |
| |
|
| | if (searchLoading) { |
| | timer = setTimeout(() => setShowSpinner(true), 1000) |
| | } else { |
| | setShowSpinner(false) |
| | } |
| |
|
| | return () => { |
| | clearTimeout(timer) |
| | } |
| | }, [ |
| | searchLoading, |
| | aiAutocompleteOptions.length, |
| | generalSearchResults.length, |
| | autoCompleteSearchError, |
| | ]) |
| |
|
| | |
| | const filteredAIOptions = aiAutocompleteOptions.filter( |
| | (option) => option.term !== urlSearchInputQuery, |
| | ) |
| |
|
| | |
| | const userInputOptions = |
| | urlSearchInputQuery.trim() !== '' |
| | ? [ |
| | { |
| | term: urlSearchInputQuery, |
| | title: urlSearchInputQuery, |
| | highlights: [], |
| | isUserQuery: true, |
| | }, |
| | ] |
| | : [] |
| |
|
| | |
| | const [combinedOptions, generalOptionsWithViewStatus, aiOptionsWithUserInput] = useMemo(() => { |
| | setAnnouncement('') |
| | let generalWithView = [...generalSearchResults] |
| | const aiWithUser = [...userInputOptions, ...filteredAIOptions] |
| | const combined = [] as Array<{ |
| | group: 'general' | 'ai' | string |
| | url?: string |
| | option: AutocompleteSearchHitWithUserQuery | GeneralSearchHitWithOptions |
| | }> |
| |
|
| | if (generalSearchResults.length > 0) { |
| | generalWithView.push({ |
| | title: t('search.overlay.view_all_search_results'), |
| | isViewAllResults: true, |
| | } as any) |
| | } else if (autoCompleteSearchError) { |
| | if (urlSearchInputQuery.trim() !== '') { |
| | generalWithView.push({ |
| | ...(userInputOptions[0] || {}), |
| | isSearchDocsOption: true, |
| | } as unknown as GeneralSearchHit) |
| | } |
| | } else if (urlSearchInputQuery.trim() !== '' && !searchLoading) { |
| | setAnnouncement(t('search.overlay.no_results_found_announcement')) |
| | generalWithView.push({ |
| | title: t('search.overlay.no_results_found'), |
| | isNoResultsFound: true, |
| | } as any) |
| | } else { |
| | generalWithView = [] |
| | } |
| | |
| | |
| | combined.push(...generalWithView.map((option) => ({ group: 'general', option }))) |
| | |
| | if (!aiSearchError && !isAskAIState) { |
| | combined.push(...aiWithUser.map((option) => ({ group: 'ai', option }))) |
| | } else if (isAskAIState && !aiCouldNotAnswer) { |
| | |
| | |
| | combined.push( |
| | ...aiReferences.map((option) => ({ |
| | group: 'reference', |
| | url: option.url, |
| | option: { |
| | term: option.title, |
| | highlights: [], |
| | isUserQuery: false, |
| | }, |
| | })), |
| | ) |
| | } |
| |
|
| | return [combined, generalWithView, aiWithUser] |
| | }, [ |
| | generalSearchResults, |
| | totalGeneralSearchResults, |
| | urlSearchInputQuery, |
| | aiSearchError, |
| | aiReferences, |
| | isAskAIState, |
| | autoCompleteSearchError, |
| | ]) |
| |
|
| | |
| | |
| | |
| | useEffect(() => { |
| | if (searchOverlayOpen) { |
| | inputRef.current?.focus({ |
| | preventScroll: true, |
| | }) |
| | } |
| | }, [searchOverlayOpen]) |
| |
|
| | |
| | useEffect(() => { |
| | if (searchOverlayOpen) { |
| | if (!searchEventGroupId.current) { |
| | searchEventGroupId.current = uuidv4() |
| | } |
| | updateAutocompleteResults(urlSearchInputQuery) |
| | } else { |
| | |
| | |
| | setSearchLoading(false) |
| | } |
| | return () => { |
| | clearAutocompleteResults() |
| | } |
| | |
| | |
| | |
| | }, [ |
| | searchOverlayOpen, |
| | updateAutocompleteResults, |
| | clearAutocompleteResults, |
| | isAskAIState, |
| | aiCouldNotAnswer, |
| | ]) |
| |
|
| | |
| | useEffect(() => { |
| | listElementsRef.current = listElementsRef.current.slice( |
| | 0, |
| | generalOptionsWithViewStatus.length + aiOptionsWithUserInput.length, |
| | ) |
| | }, [generalOptionsWithViewStatus, aiOptionsWithUserInput]) |
| |
|
| | |
| | const previousSuggestionsListHeight = useMemo(() => { |
| | if (generalSearchResults.length || aiAutocompleteOptions.length) { |
| | return `${7 * (generalSearchResults.length + aiAutocompleteOptions.length)}` |
| | } else { |
| | return '150' |
| | } |
| | }, [searchLoading]) |
| |
|
| | |
| | const handleSearchQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
| | event.preventDefault() |
| | const newQuery = event.target.value |
| | setSelectedIndex(-1) |
| | |
| | setSearchLoading(true) |
| | updateAutocompleteResults(newQuery) |
| | if (isAskAIState) { |
| | updateParams({ |
| | 'search-overlay-ask-ai': '', |
| | 'search-overlay-input': newQuery, |
| | }) |
| | } else { |
| | updateParams({ |
| | 'search-overlay-input': newQuery, |
| | }) |
| | } |
| | } |
| |
|
| | |
| | const generalSearchResultOnSelect = (selectedOption: GeneralSearchHit) => { |
| | sendEvent({ |
| | type: EventType.search, |
| | search_query: sanitizeSearchQuery(urlSearchInputQuery), |
| | search_context: GENERAL_SEARCH_CONTEXT, |
| | eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP, |
| | eventGroupId: searchEventGroupId.current, |
| | }) |
| | sendEvent({ |
| | type: EventType.searchResult, |
| | search_result_query: sanitizeSearchQuery(urlSearchInputQuery), |
| | search_result_index: selectedIndex, |
| | search_result_total: totalGeneralSearchResults, |
| | search_result_url: selectedOption.url || '', |
| | search_result_rank: (totalGeneralSearchResults - selectedIndex) / totalGeneralSearchResults, |
| | eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP, |
| | eventGroupId: searchEventGroupId.current, |
| | }) |
| | const searchParams = new URLSearchParams((router.query as Record<string, string>) || {}) |
| | if (searchParams.has('search-overlay-open')) { |
| | searchParams.delete('search-overlay-open') |
| | } |
| | if (searchParams.has('search-overlay-input')) { |
| | searchParams.delete('search-overlay-input') |
| | } |
| | if (searchParams.has('search-overlay-ask-ai')) { |
| | searchParams.delete('search-overlay-ask-ai') |
| | } |
| | if (searchParams.has('query')) { |
| | searchParams.delete('query') |
| | } |
| | router.push(`${selectedOption.url}?${searchParams.toString()}`) |
| | onClose() |
| | } |
| |
|
| | |
| | const aiSearchOptionOnSelect = (selectedOption: AutocompleteSearchHit) => { |
| | if (selectedOption.term) { |
| | askAIEventGroupId.current = uuidv4() |
| | |
| | sendEvent({ |
| | type: EventType.search, |
| | search_query: 'REDACTED', |
| | search_context: AI_SEARCH_CONTEXT, |
| | eventGroupKey: ASK_AI_EVENT_GROUP, |
| | eventGroupId: askAIEventGroupId.current, |
| | }) |
| | setSelectedIndex(-1) |
| | setSearchLoading(true) |
| | updateParams({ |
| | 'search-overlay-ask-ai': 'true', |
| | 'search-overlay-input': selectedOption.term, |
| | }) |
| | setAIQuery(selectedOption.term) |
| | inputRef.current?.focus() |
| | } |
| | } |
| |
|
| | const performGeneralSearch = () => { |
| | executeGeneralSearch(router, currentVersion, urlSearchInputQuery, debug) |
| | onClose() |
| | } |
| |
|
| | |
| | const referenceOnSelect = (url: string) => { |
| | sendEvent({ |
| | type: EventType.link, |
| | link_url: url || '', |
| | eventGroupKey: ASK_AI_EVENT_GROUP, |
| | eventGroupId: askAIEventGroupId.current, |
| | }) |
| | setSelectedIndex(-1) |
| | const searchParams = new URLSearchParams((router.query as Record<string, string>) || {}) |
| | if (searchParams.has('search-overlay-open')) { |
| | searchParams.delete('search-overlay-open') |
| | } |
| | if (searchParams.has('search-overlay-input')) { |
| | searchParams.delete('search-overlay-input') |
| | } |
| | if (searchParams.has('search-overlay-ask-ai')) { |
| | searchParams.delete('search-overlay-ask-ai') |
| | } |
| | if (searchParams.has('query')) { |
| | searchParams.delete('query') |
| | } |
| | window.open(`${url}?${searchParams.toString()}`, '_blank') |
| | } |
| |
|
| | |
| | const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => { |
| | const optionsLength = listElementsRef.current?.length ?? 0 |
| | if (event.key === 'ArrowDown') { |
| | event.preventDefault() |
| | if (optionsLength > 0) { |
| | let newIndex = 0 |
| | |
| | if (selectedIndex === -1) { |
| | newIndex = 0 |
| | } else { |
| | newIndex = (selectedIndex + 1) % optionsLength |
| | |
| | if (newIndex < selectedIndex) { |
| | newIndex = -1 |
| | } |
| | } |
| | |
| | if ( |
| | newIndex >= selectedIndex && |
| | (combinedOptions[newIndex]?.option as any)?.isNoResultsFound |
| | ) { |
| | newIndex += 1 |
| | } |
| | setSelectedIndex(newIndex) |
| | if (newIndex !== -1 && listElementsRef.current[newIndex]) { |
| | listElementsRef.current[newIndex]?.scrollIntoView({ |
| | behavior: 'smooth', |
| | block: 'center', |
| | }) |
| | } |
| | } |
| | } else if (event.key === 'ArrowUp') { |
| | event.preventDefault() |
| | if (optionsLength > 0) { |
| | let newIndex = 0 |
| | |
| | if (selectedIndex === -1) { |
| | newIndex = optionsLength - 1 |
| | } else { |
| | |
| | newIndex = (selectedIndex - 1 + optionsLength) % optionsLength |
| | |
| | if (newIndex > selectedIndex) { |
| | newIndex = -1 |
| | |
| | } |
| | } |
| | |
| | if ( |
| | newIndex <= selectedIndex && |
| | (combinedOptions[newIndex]?.option as any)?.isNoResultsFound |
| | ) { |
| | newIndex -= 1 |
| | } |
| | setSelectedIndex(newIndex) |
| | if (newIndex !== -1 && listElementsRef.current[newIndex]) { |
| | listElementsRef.current[newIndex]?.scrollIntoView({ |
| | behavior: 'smooth', |
| | block: 'center', |
| | }) |
| | } |
| | } |
| | } else if (event.key === 'Enter') { |
| | event.preventDefault() |
| | let pressedGroupKey = SEARCH_OVERLAY_EVENT_GROUP |
| | let pressedGroupId = searchEventGroupId |
| | let pressedOnContext = '' |
| |
|
| | |
| | if (selectedIndex === -1) { |
| | pressedOnContext = AI_SEARCH_CONTEXT |
| | pressedGroupKey = ASK_AI_EVENT_GROUP |
| | pressedGroupId = askAIEventGroupId |
| | sendKeyboardEvent(event.key, pressedOnContext, pressedGroupId, pressedGroupKey) |
| | aiSearchOptionOnSelect({ term: urlSearchInputQuery } as AutocompleteSearchHit) |
| | } else if ( |
| | combinedOptions.length > 0 && |
| | selectedIndex >= 0 && |
| | selectedIndex < combinedOptions.length |
| | ) { |
| | const selectedItem = combinedOptions[selectedIndex] |
| | if (!selectedItem) { |
| | return |
| | } |
| | let action = () => {} |
| | if (selectedItem?.group === 'general') { |
| | if ( |
| | (selectedItem.option as GeneralSearchHitWithOptions).isViewAllResults || |
| | (selectedItem.option as GeneralSearchHitWithOptions).isSearchDocsOption |
| | ) { |
| | pressedOnContext = 'view-all' |
| | action = performGeneralSearch |
| | } else { |
| | pressedOnContext = 'general-option' |
| | action = () => generalSearchResultOnSelect(selectedItem.option as GeneralSearchHit) |
| | } |
| | } else if (selectedItem?.group === 'ai') { |
| | pressedOnContext = 'ai-option' |
| | action = () => aiSearchOptionOnSelect(selectedItem.option as AutocompleteSearchHit) |
| | } else if (selectedItem?.group === 'reference') { |
| | |
| | pressedGroupKey = ASK_AI_EVENT_GROUP |
| | pressedGroupId = askAIEventGroupId |
| | pressedOnContext = 'reference-option' |
| | action = () => referenceOnSelect(selectedItem.url || '') |
| | } |
| | sendKeyboardEvent(event.key, pressedOnContext, pressedGroupId, pressedGroupKey) |
| | return action() |
| | } |
| | } else if (event.key === 'Escape') { |
| | event.preventDefault() |
| | onClose() |
| | } |
| | } |
| |
|
| | const onBackButton = () => { |
| | |
| | setSelectedIndex(-1) |
| | updateParams({ |
| | 'search-overlay-ask-ai': '', |
| | 'search-overlay-input': urlSearchInputQuery, |
| | }) |
| | |
| | inputRef.current?.focus() |
| | } |
| |
|
| | |
| | |
| | const askAIState = { |
| | isAskAIState, |
| | aiQuery, |
| | debug, |
| | currentVersion, |
| | setAISearchError: (isError = true) => { |
| | setAISearchError(isError) |
| | if (isError) { |
| | updateParams({ |
| | 'search-overlay-ask-ai': '', |
| | }) |
| | } |
| | }, |
| | references: aiReferences, |
| | setReferences: setAIReferences, |
| | referencesIndexOffset: generalOptionsWithViewStatus.length, |
| | referenceOnSelect, |
| | askAIEventGroupId, |
| | aiSearchError, |
| | aiCouldNotAnswer, |
| | setAICouldNotAnswer, |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | let OverlayContents = null |
| | |
| | const inErrorState = aiSearchError || (autoCompleteSearchError && !isAskAIState) |
| | if (inErrorState) { |
| | OverlayContents = ( |
| | <> |
| | <ActionList |
| | aria-label={t('search.overlay.suggestions_list_aria_label')} |
| | id="search-suggestions-list" |
| | showDividers |
| | className={styles.suggestionsList} |
| | ref={suggestionsListHeightRef} |
| | style={{ |
| | minHeight: |
| | autoCompleteSearchError && !generalOptionsWithViewStatus.length |
| | ? '0' |
| | : `${previousSuggestionsListHeight}px`, |
| | }} |
| | > |
| | {/* Always show the AI Search UI error message when it is needed */} |
| | {aiSearchError && ( |
| | <> |
| | <ActionList.Divider key="error-top-divider" /> |
| | <li tabIndex={-1}> |
| | <ActionList.GroupHeading |
| | as="h3" |
| | tabIndex={-1} |
| | aria-label={t('search.overlay.ai_suggestions_list_aria_label')} |
| | > |
| | <CopilotIcon className="mr-1" /> |
| | {t('search.overlay.ai_autocomplete_list_heading')} |
| | </ActionList.GroupHeading> |
| | </li> |
| | <li> |
| | <div className={styles.overlayPadding}> |
| | <Banner |
| | tabIndex={0} |
| | className={styles.errorBanner} |
| | title={t('search.failure.ai_title')} |
| | description={t('search.failure.description')} |
| | variant="info" |
| | aria-live="assertive" |
| | role="alert" |
| | /> |
| | </div> |
| | </li> |
| | {/* If there are general results, show bottom divider */} |
| | {generalOptionsWithViewStatus.length > 0 && ( |
| | <ActionList.Divider key="error-middle-divider" /> |
| | )} |
| | </> |
| | )} |
| | {renderSearchGroups( |
| | t, |
| | generalOptionsWithViewStatus, |
| | aiSearchError ? [] : aiOptionsWithUserInput, |
| | generalSearchResultOnSelect, |
| | aiSearchOptionOnSelect, |
| | performGeneralSearch, |
| | selectedIndex, |
| | listElementsRef, |
| | askAIState, |
| | showSpinner, |
| | searchLoading, |
| | previousSuggestionsListHeight, |
| | )} |
| | </ActionList> |
| | </> |
| | ) |
| | } else { |
| | OverlayContents = ( |
| | <ActionList |
| | id="search-suggestions-list" |
| | aria-label={t('search.overlay.suggestions_list_aria_label')} |
| | showDividers |
| | className={styles.suggestionsList} |
| | ref={suggestionsListHeightRef} |
| | style={{ |
| | minHeight: `${previousSuggestionsListHeight}px`, |
| | }} |
| | > |
| | {renderSearchGroups( |
| | t, |
| | generalOptionsWithViewStatus, |
| | aiOptionsWithUserInput, |
| | generalSearchResultOnSelect, |
| | aiSearchOptionOnSelect, |
| | performGeneralSearch, |
| | selectedIndex, |
| | listElementsRef, |
| | askAIState, |
| | showSpinner, |
| | searchLoading, |
| | previousSuggestionsListHeight, |
| | )} |
| | </ActionList> |
| | ) |
| | } |
| |
|
| | return ( |
| | <> |
| | <div className={styles.overlayBackdrop} /> |
| | <Overlay |
| | preventFocusOnOpen |
| | initialFocusRef={inputRef} |
| | returnFocusRef={parentRef} |
| | ignoreClickRefs={[parentRef]} |
| | onEscape={onClose} |
| | onClickOutside={onClose} |
| | anchorSide="inside-center" |
| | className={cx(styles.overlayContainer, 'position-fixed')} |
| | // We need to override the top value of the overlay when there are header notifications |
| | style={ |
| | hasOpenHeaderNotifications |
| | ? { |
| | top: overlayTopValue, |
| | } |
| | : undefined |
| | } |
| | role="dialog" |
| | aria-modal="true" |
| | aria-label={t('search.overlay.aria_label')} |
| | ref={overlayRef} |
| | > |
| | <div className={styles.header}> |
| | <div className={isAskAIState ? styles.askAILabel : styles.askAILabelHidden}> |
| | <IconButton |
| | aria-label={t('search.ai.back_to_search')} |
| | icon={ArrowLeftIcon} |
| | onClick={onBackButton} |
| | variant="invisible" |
| | ></IconButton> |
| | </div> |
| | <TextInput |
| | className="width-full" |
| | data-testid="overlay-search-input" |
| | ref={inputRef} |
| | value={urlSearchInputQuery} |
| | onChange={handleSearchQueryChange} |
| | leadingVisual={<SearchIcon />} |
| | role="combobox" |
| | // In AskAI the search input not longer "controls" the suggestions list, because there is no list, so we remove the aria-controls attribute |
| | aria-controls={isAskAIState ? 'ask-ai-result-container' : 'search-suggestions-list'} |
| | aria-expanded={combinedOptions.length > 0} |
| | aria-label={t('search.overlay.input_aria_label')} |
| | aria-activedescendant={ |
| | selectedIndex >= 0 |
| | ? `search-option-${combinedOptions[selectedIndex]?.group}-${selectedIndex}` |
| | : undefined |
| | } |
| | onKeyDown={handleKeyDown} |
| | placeholder={t('search.input.placeholder_no_icon')} |
| | trailingAction={ |
| | <Stack justify="center" className={styles.stackMinWidth}> |
| | <TextInput.Action |
| | onClick={() => { |
| | setSelectedIndex(-1) |
| | if (isAskAIState) { |
| | updateParams({ |
| | 'search-overlay-ask-ai': '', |
| | 'search-overlay-input': '', |
| | }) |
| | } else { |
| | updateParams({ |
| | 'search-overlay-input': '', |
| | }) |
| | } |
| | if (!isAskAIState) { |
| | updateAutocompleteResults('') |
| | } |
| | }} |
| | icon={XCircleFillIcon} |
| | aria-label={t('search.overlay.clear_search_query')} |
| | /> |
| | </Stack> |
| | } |
| | /> |
| | </div> |
| | <ActionList.Divider |
| | className={inErrorState ? styles.dividerTopMarginHidden : styles.dividerTopMargin} |
| | aria-hidden="true" |
| | /> |
| | {OverlayContents} |
| | <ActionList.Divider className={styles.dividerFullWidth} /> |
| | <div key="description" className={styles.footer}> |
| | <p |
| | className={styles.privacyDisclaimer} |
| | dangerouslySetInnerHTML={{ __html: t('search.overlay.privacy_disclaimer') }} |
| | /> |
| | </div> |
| | <div aria-live="assertive" className={styles.screenReaderOnly}> |
| | {announcement} |
| | </div> |
| | </Overlay> |
| | </> |
| | ) |
| | } |
| |
|
| | interface AutocompleteSearchHitWithUserQuery extends AutocompleteSearchHit { |
| | isUserQuery?: boolean |
| | } |
| |
|
| | interface GeneralSearchHitWithOptions extends GeneralSearchHit { |
| | isViewAllResults?: boolean |
| | isNoResultsFound?: boolean |
| | isSearchDocsOption?: boolean |
| | } |
| |
|
| | |
| | function renderSearchGroups( |
| | t: any, |
| | generalSearchOptions: GeneralSearchHitWithOptions[], |
| | aiOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[], |
| | generalSearchResultOnSelect: (selectedOption: GeneralSearchHit) => void, |
| | aiAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void, |
| | performGeneralSearch: () => void, |
| | selectedIndex: number, |
| | listElementsRef: RefObject<Array<HTMLLIElement | null>>, |
| | askAIState: { |
| | isAskAIState: boolean |
| | aiQuery: string |
| | debug: boolean |
| | currentVersion: string |
| | setAISearchError: () => void |
| | references: AIReference[] |
| | setReferences: (value: SetStateAction<AIReference[]>) => void |
| | referencesIndexOffset: number |
| | referenceOnSelect: (url: string) => void |
| | askAIEventGroupId: React.MutableRefObject<string> |
| | aiSearchError: boolean |
| | aiCouldNotAnswer: boolean |
| | setAICouldNotAnswer: (value: boolean) => void |
| | }, |
| | showSpinner: boolean, |
| | searchLoading: boolean, |
| | previousSuggestionsListHeight: number | string, |
| | ) { |
| | const groups = [] |
| |
|
| | const isInAskAIState = askAIState?.isAskAIState && !askAIState.aiSearchError |
| | const isInAskAIStateButNoAnswer = isInAskAIState && askAIState.aiCouldNotAnswer |
| |
|
| | |
| | |
| | if (showSpinner && !isInAskAIState) { |
| | groups.push( |
| | <div |
| | key="loading" |
| | role="status" |
| | className={styles.loadingContainer} |
| | style={{ |
| | height: `${previousSuggestionsListHeight}px`, |
| | }} |
| | > |
| | <Spinner /> |
| | </div>, |
| | ) |
| | return groups |
| | } |
| |
|
| | |
| | if (generalSearchOptions.length || isInAskAIStateButNoAnswer) { |
| | const items = [] |
| | for (let index = 0; index < generalSearchOptions.length; index++) { |
| | const option = generalSearchOptions[index] |
| | if (option.isNoResultsFound) { |
| | items.push( |
| | <ActionList.Item |
| | key={`general-${index}`} |
| | id={`search-option-general-${index}`} |
| | className={styles.noResultsFound} |
| | tabIndex={-1} |
| | aria-label={t('search.overlay.no_results_found')} |
| | disabled |
| | > |
| | {option.title} |
| | </ActionList.Item>, |
| | ) |
| | |
| | break |
| | |
| | } else if (option.isSearchDocsOption) { |
| | const isActive = selectedIndex === index |
| | items.push( |
| | <ActionList.Item |
| | key={`general-${index}`} |
| | id={`search-option-general-${index}`} |
| | tabIndex={-1} |
| | active={isActive} |
| | onSelect={() => performGeneralSearch()} |
| | aria-label={t('search.overlay.search_docs_with_query').replace('{query}', option.title)} |
| | ref={(element: HTMLLIElement | null) => { |
| | if (listElementsRef.current) { |
| | listElementsRef.current[index] = element |
| | } |
| | }} |
| | > |
| | <ActionList.LeadingVisual aria-hidden> |
| | <SearchIcon /> |
| | </ActionList.LeadingVisual> |
| | {option.title} |
| | <ActionList.TrailingVisual |
| | aria-hidden |
| | className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden} |
| | > |
| | <ArrowRightIcon /> |
| | </ActionList.TrailingVisual> |
| | </ActionList.Item>, |
| | ) |
| | } else if (option.title) { |
| | const isActive = selectedIndex === index |
| | items.push( |
| | <ActionList.Item |
| | key={`general-${index}`} |
| | id={`search-option-general-${index}`} |
| | aria-describedby="search-suggestions-list" |
| | onSelect={() => |
| | option.isViewAllResults ? performGeneralSearch() : generalSearchResultOnSelect(option) |
| | } |
| | className={option.isViewAllResults ? styles.viewAllSearchResults : ''} |
| | active={isActive} |
| | tabIndex={-1} |
| | ref={(element: HTMLLIElement | null) => { |
| | if (listElementsRef.current) { |
| | listElementsRef.current[index] = element |
| | } |
| | }} |
| | > |
| | {!option.isNoResultsFound && ( |
| | <ActionList.LeadingVisual |
| | aria-hidden |
| | className={ |
| | option.isViewAllResults ? styles.leadingVisualHidden : styles.leadingVisualVisible |
| | } |
| | > |
| | <FileIcon /> |
| | </ActionList.LeadingVisual> |
| | )} |
| | {option.title} |
| | <ActionList.TrailingVisual |
| | aria-hidden |
| | className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden} |
| | > |
| | <ArrowRightIcon /> |
| | </ActionList.TrailingVisual> |
| | </ActionList.Item>, |
| | ) |
| | } |
| | } |
| |
|
| | groups.push( |
| | <ActionList.Group key="general" data-testid="general-autocomplete-suggestions"> |
| | <ActionList.GroupHeading as="h3" tabIndex={-1}> |
| | {t('search.overlay.general_suggestions_list_heading')} |
| | </ActionList.GroupHeading> |
| | {searchLoading && isInAskAIState ? ( |
| | <div |
| | role="status" |
| | className={styles.loadingContainer} |
| | style={{ |
| | height: `${previousSuggestionsListHeight}px`, |
| | }} |
| | > |
| | <Spinner /> |
| | </div> |
| | ) : ( |
| | items |
| | )} |
| | </ActionList.Group>, |
| | ) |
| |
|
| | if (isInAskAIState || isInAskAIStateButNoAnswer) { |
| | groups.push(<ActionList.Divider key="no-answer-divider" />) |
| | } |
| |
|
| | if (isInAskAIState) { |
| | groups.push( |
| | <ActionList.Group key="ai" data-testid="ask-ai"> |
| | <li tabIndex={-1}> |
| | <AskAIResults |
| | query={askAIState.aiQuery} |
| | debug={askAIState.debug} |
| | version={askAIState.currentVersion} |
| | setAISearchError={askAIState.setAISearchError} |
| | references={askAIState.references} |
| | setReferences={askAIState.setReferences} |
| | referencesIndexOffset={askAIState.referencesIndexOffset} |
| | referenceOnSelect={askAIState.referenceOnSelect} |
| | selectedIndex={selectedIndex} |
| | askAIEventGroupId={askAIState.askAIEventGroupId} |
| | aiCouldNotAnswer={askAIState.aiCouldNotAnswer} |
| | setAICouldNotAnswer={askAIState.setAICouldNotAnswer} |
| | listElementsRef={listElementsRef} |
| | /> |
| | </li> |
| | </ActionList.Group>, |
| | ) |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | if ( |
| | !isInAskAIState && |
| | !askAIState.aiSearchError && |
| | generalSearchOptions.filter((option) => !option.isViewAllResults && !option.isNoResultsFound) |
| | .length && |
| | aiOptionsWithUserInput.length |
| | ) { |
| | groups.push(<ActionList.Divider key="bottom-divider" />) |
| | } |
| | } |
| |
|
| | if (aiOptionsWithUserInput.length && !isInAskAIState) { |
| | groups.push( |
| | <ActionList.Group key="ai-suggestions" data-testid="ai-autocomplete-suggestions"> |
| | <ActionList.GroupHeading as="h3" id="copilot-suggestions" tabIndex={-1}> |
| | <CopilotIcon className="mr-1" /> |
| | {t('search.overlay.ai_autocomplete_list_heading')} |
| | </ActionList.GroupHeading> |
| | {aiOptionsWithUserInput.map((option: AutocompleteSearchHitWithUserQuery, index: number) => { |
| | // Since general search comes first, we need to add an offset for AI suggestions |
| | const indexWithOffset = generalSearchOptions.length + index |
| | const isActive = selectedIndex === indexWithOffset |
| | const item = ( |
| | <ActionList.Item |
| | key={`ai-${indexWithOffset}`} |
| | id={`search-option-ai-${indexWithOffset}`} |
| | aria-describedby="copilot-suggestions" |
| | onSelect={() => aiAutocompleteOnSelect(option)} |
| | active={isActive} |
| | tabIndex={-1} |
| | ref={(element: HTMLLIElement | null) => { |
| | if (listElementsRef.current) { |
| | listElementsRef.current[index] = element |
| | } |
| | }} |
| | > |
| | <ActionList.LeadingVisual aria-hidden> |
| | <CommentIcon /> |
| | </ActionList.LeadingVisual> |
| | {option.term} |
| | <ActionList.TrailingVisual |
| | aria-hidden |
| | className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden} |
| | > |
| | <ArrowRightIcon /> |
| | </ActionList.TrailingVisual> |
| | </ActionList.Item> |
| | ) |
| | return item |
| | })} |
| | </ActionList.Group>, |
| | ) |
| | } |
| |
|
| | return groups |
| | } |
| |
|
| | function sendKeyboardEvent( |
| | pressedKey: string, |
| | pressedOn: string, |
| | searchEventGroupId: React.MutableRefObject<string>, |
| | searchEventGroupKey = SEARCH_OVERLAY_EVENT_GROUP, |
| | ) { |
| | sendEvent({ |
| | type: EventType.keyboard, |
| | pressed_key: pressedKey, |
| | pressed_on: pressedOn, |
| | eventGroupKey: searchEventGroupKey, |
| | eventGroupId: searchEventGroupId.current, |
| | }) |
| | } |
| |
|