import React, { Fragment, useRef, useState, useEffect, useCallback } from 'react'; import styled from 'styled-components'; import type { JSX, PointerEvent } from 'react'; import type { SearchFacetCount, SearchItemData } from '@redocly/theme/core/types'; import { SearchInput } from '@redocly/theme/components/Search/SearchInput'; import { SearchShortcut } from '@redocly/theme/components/Search/SearchShortcut'; import { Button } from '@redocly/theme/components/Button/Button'; import { breakpoints, concatClassNames } from '@redocly/theme/core/utils'; import { Portal } from '@redocly/theme/components/Portal/Portal'; import { SearchItem } from '@redocly/theme/components/Search/SearchItem'; import { SearchRecent } from '@redocly/theme/components/Search/SearchRecent'; import { SearchSuggestedPages } from '@redocly/theme/components/Search/SearchSuggestedPages'; import { useThemeHooks, useDialogHotKeys, useSearchFilter, useRecentSearches, useModalScrollLock, } from '@redocly/theme/core/hooks'; import { Tag } from '@redocly/theme/components/Tag/Tag'; import { CloseIcon } from '@redocly/theme/icons/CloseIcon/CloseIcon'; import { SearchFilter } from '@redocly/theme/components/Search/SearchFilter'; import { SearchGroups } from '@redocly/theme/components/Search/SearchGroups'; import { Typography } from '@redocly/theme/components/Typography/Typography'; import { SpinnerLoader } from '@redocly/theme/components/Loaders/SpinnerLoader'; import { SearchAiDialog } from '@redocly/theme/components/Search/SearchAiDialog'; import { useSearchSession } from '@redocly/theme/core/contexts'; import { SettingsIcon } from '@redocly/theme/icons/SettingsIcon/SettingsIcon'; import { AiStarsIcon } from '@redocly/theme/icons/AiStarsIcon/AiStarsIcon'; import { ReturnKeyIcon } from '@redocly/theme/icons/ReturnKeyIcon/ReturnKeyIcon'; import { ChevronLeftIcon } from '@redocly/theme/icons/ChevronLeftIcon/ChevronLeftIcon'; import { EditIcon } from '@redocly/theme/icons/EditIcon/EditIcon'; import { AiStarsGradientIcon } from '@redocly/theme/icons/AiStarsGradientIcon/AiStarsGradientIcon'; import { ResetIcon } from '@redocly/theme/icons/ResetIcon/ResetIcon'; import { CloseFilledIcon } from '@redocly/theme/icons/CloseFilledIcon/CloseFilledIcon'; export type SearchDialogProps = { onClose: () => void; className?: string; initialMode?: 'search' | 'ai-dialog'; }; export function SearchDialog({ onClose, className, initialMode = 'search', }: SearchDialogProps): JSX.Element { const { useTranslate, useCurrentProduct, useSearch, useProducts, useAiSearch, useTelemetry } = useThemeHooks(); const telemetry = useTelemetry(); const { searchSessionId, refreshSearchSessionId } = useSearchSession(); const products = useProducts(); const currentProduct = useCurrentProduct(); const [product, setProduct] = useState(currentProduct); const [mode, setMode] = useState<'search' | 'ai-dialog'>(initialMode); const autoSearchDisabled = mode !== 'search'; const { query, setQuery, filter, setFilter, items, isSearchLoading, facets, setLoadMore, advancedSearch, askAi, groupField, searchError, retrySearch, } = useSearch(product?.name, autoSearchDisabled); const { isFilterOpen, onFilterToggle, onFilterChange, onFilterReset, onFacetReset, onQuickFilterReset, } = useSearchFilter(filter, setFilter); const { addSearchHistoryItem } = useRecentSearches(); const aiSearch = useAiSearch({ filter: filter, product: product?.name }); useModalScrollLock(true); const searchInputRef = useRef(null); const modalRef = useRef(null); const [isMobile, setIsMobile] = useState(false); useEffect(() => { const mediaQuery = window.matchMedia(`(max-width: ${breakpoints.small})`); setIsMobile(mediaQuery.matches); const handleChange = (e: MediaQueryListEvent) => { setIsMobile(e.matches); }; mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); }, []); const aiQueryRef = useRef(null); const firstSearchResultRef = useRef(null); const searchKeysWithResults = items ? Object.keys(items).filter((key) => items[key]?.length) : []; const { translate } = useTranslate(); const handleClose = useCallback(() => { const value = searchInputRef?.current?.value; if (value) { addSearchHistoryItem(value); } // Refresh the search session id so a new session starts on next open refreshSearchSessionId(); onClose(); }, [addSearchHistoryItem, onClose, refreshSearchSessionId]); useDialogHotKeys(modalRef, handleClose); const focusSearchInput = () => { requestAnimationFrame(() => { searchInputRef.current?.focus(); }); }; useEffect(() => { if (mode === 'ai-dialog' && aiSearch.isGeneratingResponse) { setQuery(''); } }, [mode, aiSearch.isGeneratingResponse, setQuery]); useEffect(focusSearchInput, []); const handleOverlayClick = useCallback( (event: PointerEvent) => { if (event.button !== 0) return; const target = event.target as HTMLElement; if (typeof target.className !== 'string') return; if (target.className?.includes(' overlay')) { handleClose(); } }, [handleClose], ); const mapItem = useCallback( ( item: SearchItemData, index: number, results: SearchItemData[], innerRef?: React.Ref, ) => { let itemProduct; if (!product && item.document.product) { const folder = item.document.product?.folder; const resolvedProduct = products.find((product) => product.slug.match(`/${folder.startsWith('./') ? folder.slice(2) : folder}/`), ); itemProduct = resolvedProduct ? { name: resolvedProduct.name, icon: resolvedProduct.icon } : undefined; } return ( { addSearchHistoryItem(query); telemetry.sendSearchResultClickedMessage([ { object: 'search', query: query, url: item.document.url, totalResults: results.length.toString(), index: index.toString(), searchEngine: mode, searchSessionId, }, ]); onClose(); }} /> ); }, [onClose, product, products, addSearchHistoryItem, query, telemetry, mode, searchSessionId], ); const showLoadMore = useCallback( (groupKey: string, currentCount: number = 0) => { const groupFacet = facets.find((facet) => facet.field === groupField); let needLoadMore = false; if (groupFacet) { const groupValue = groupFacet.values.find((value) => { if (typeof value === 'object') { return value.value === groupKey; } else return false; }) as SearchFacetCount; needLoadMore = groupValue ? groupValue.count > currentCount : false; } return needLoadMore; }, [facets, groupField], ); const showResults = !!((filter && filter.length) || query); const showSearchFilterButton = advancedSearch && mode === 'search'; const showAiSearchButton = askAi && mode === 'search'; const showAiSearchItem = showAiSearchButton && query; const showHeaderButtons = showSearchFilterButton || showAiSearchButton; return ( {product && ( {product.name} setProduct(undefined)} color="--icon-color-additional" /> )} {mode === 'search' ? ( <> { if (isSearchLoading) return; if (showAiSearchButton && aiQueryRef.current) { aiQueryRef.current.focus(); } else { firstSearchResultRef.current?.focus(); } }} data-translation-key="search.label" /> {showHeaderButtons && ( {showAiSearchButton ? ( } onClick={() => { setMode('ai-dialog'); if (query.trim()) { aiSearch.askQuestion(query); } telemetry.sendSearchAiOpenedMessage([ { object: 'search', method: 'ai_search_button', }, ]); }} > {translate('search.ai.button', 'Search with AI')} ) : null} {showSearchFilterButton && ( } onClick={onFilterToggle} /> )} )} ) : ( {initialMode === 'ai-dialog' ? ( {translate('search.ai.assistant', 'Assistant')} ) : ( )} {isMobile && ) : ( {translate('search.noResults.title', 'No results')} ) ) : ( <> { telemetry.sendSearchRecentClickedMessage([ { object: 'search', query, index: index.toString(), searchSessionId, }, ]); setQuery(query); focusSearchInput(); }} /> )} ) : ( )} {mode === 'ai-dialog' ? ( {translate( 'search.ai.disclaimer', 'AI search might provide incomplete or incorrect results. Verify important information.', )} ) : ( <> {isSearchLoading && ( {translate('search.loading', 'Loading...')} )} {translate('search.cancel', 'Cancel')} )} ); } const SearchOverlay = styled.div` position: fixed; display: flex; align-items: center; justify-content: center; top: 0; left: 0; width: 100vw; height: 100vh; background: var(--bg-color-modal-overlay); z-index: var(--z-index-overlay); @media screen and (max-width: ${breakpoints.small}) { align-items: start; position: fixed; overflow: hidden; overscroll-behavior: none; } `; const SearchDialogWrapper = styled.div` display: flex; flex-direction: column; overflow: auto; width: 100vw; height: 100vh; background: var(--search-modal-bg-color); box-shadow: var(--search-modal-box-shadow); border-radius: 0; @media screen and (max-width: ${breakpoints.small}) { min-height: -webkit-fill-available; min-height: 100dvh; height: 100dvh; width: 100vw; } @media screen and (min-width: ${breakpoints.small}) { border-radius: var(--search-modal-border-radius); width: var(--search-modal-width); min-height: var(--search-modal-min-height); min-width: var(--search-modal-min-width); max-width: 95vw; max-height: 95vh; height: var(--search-modal-min-height); resize: both; } `; const SearchDialogHeader = styled.header` display: flex; align-items: center; border-bottom: var(--search-modal-border); background-color: var(--search-modal-header-bg-color); padding: var(--search-modal-header-padding); `; const AiDialogHeaderWrapper = styled.div` display: flex; justify-content: space-between; align-items: center; width: 100%; `; const AiDialogHeaderActionsWrapper = styled.div` display: flex; gap: var(--spacing-xxs); `; const AiDialogHeaderTitle = styled.span` display: flex; align-items: center; gap: var(--spacing-xs); font-weight: var(--font-weight-semibold); font-size: var(--font-size-lg); `; const SearchDialogBody = styled.div` display: flex; flex-direction: row-reverse; flex: 1; min-height: 0; overflow: hidden; @media screen and (max-width: ${breakpoints.small}) { min-height: 0; } `; const SearchDialogBodyMainView = styled.div` flex: 2; flex-grow: 2; overflow-y: scroll; overscroll-behavior: contain; border-right: var(--search-modal-border); `; const SearchDialogBodyFilterView = styled.div` overflow: scroll; max-width: var(--search-filter-width); width: 100%; `; const SearchDialogFooter = styled.footer` display: flex; gap: var(--search-modal-footer-gap); padding: var(--search-modal-footer-padding); border-top: var(--search-modal-border); `; const SearchShortcuts = styled.div` display: none; justify-content: flex-start; align-items: center; gap: var(--search-shortcuts-gap); @media screen and (min-width: ${breakpoints.small}) { display: flex; } `; const SearchMessage = styled.div` display: flex; height: 40%; justify-content: center; align-items: center; flex-direction: column; font-size: var(--search-message-font-size); font-weight: var(--search-message-font-weight); line-height: var(--search-message-line-height); color: var(--search-message-text-color); gap: var(--search-message-gap); `; const SearchProductTag = styled(Tag)` --tag-border-radius: var(--border-radius); border: none; margin: var(--spacing-xs) var(--spacing-sm) !important; `; const SearchFilterToggleButton = styled(Button)` margin-left: 0; `; const SearchAiButton = styled(Button)` margin-left: 0; `; const SearchCancelButton = styled(Button)` width: 100%; @media screen and (min-width: ${breakpoints.small}) { display: none; } `; const SearchGroupTitle = styled.div` border-bottom: var(--search-modal-border); padding: var(--search-group-title-padding); background-color: var(--search-group-title-bg-color); `; const SearchGroupFooter = styled.div` display: flex; justify-content: center; padding: var(--search-group-footer-padding); color: var(--search-group-footer-text-color); cursor: pointer; `; const SearchLoading = styled.div` display: none; align-items: center; gap: var(--spacing-xs); @media screen and (min-width: ${breakpoints.small}) { display: flex; } `; const SearchHeaderButtons = styled.div` display: flex; gap: var(--search-header-buttons-gap); padding-left: var(--search-header-buttons-padding-left); border-left: var(--search-header-buttons-border-left); `; const AiDisclaimer = styled.div` font-size: var(--search-ai-disclaimer-font-size); line-height: var(--search-ai-disclaimer-line-height); color: var(--search-ai-disclaimer-text-color); margin: 0 auto; text-align: center; `; const SearchWithAI = styled.div` display: flex; justify-content: flex-start; align-items: center; cursor: pointer; gap: var(--spacing-unit); padding: var(--spacing-md); color: var(--search-item-text-color); background-color: var(--search-item-bg-color); text-decoration: none; white-space: normal; outline: none; border-top: 1px solid var(--search-item-border-color); border-bottom: 1px solid var(--search-item-border-color); transition: all 0.3s ease; ${ReturnKeyIcon} { opacity: 0; } &:focus, &:hover { color: var(--search-item-text-color-hover); background-color: var(--search-item-bg-color-hover); ${ReturnKeyIcon} { opacity: 1; } } &:focus { border-top: 1px solid var(--search-item-border-color-focused); border-bottom: 1px solid var(--search-item-border-color-focused); } & > :first-child { margin-right: var(--spacing-xs); } `; const QueryWrapper = styled.div` word-break: break-word; `;