diff --git a/src/js/components/ManageMenu/SearchResults/SearchResults.tsx b/src/js/components/ManageMenu/SearchResults/SearchResults.tsx index 27058df70..28c272380 100644 --- a/src/js/components/ManageMenu/SearchResults/SearchResults.tsx +++ b/src/js/components/ManageMenu/SearchResults/SearchResults.tsx @@ -298,6 +298,52 @@ const AiSearchIntro = () => {__('AI search finds community snippets by what they do, so describe what you need in your own words.', 'code-snippets')}

+interface TabCountProps { + tab: SubnavTab + search: UniversalSearch + aiSearch: UniversalSearch + isAiTab: boolean +} + +const getTabCount = ({ tab, search, aiSearch, isAiTab }: TabCountProps): number | undefined => { + if (AI_SEARCH_TAB === tab.name) { + return isAiTab && 0 === aiSearch.loadingSources.length ? aiSearch.total : undefined + } + + if (isLockedTab(tab.name)) { + return undefined + } + + if ('all' === tab.name) { + return 0 === search.loadingSources.length ? search.total : undefined + } + + return search.loadingSources.includes(tab.name) + ? undefined + : search.groups.find(group => group.source === tab.name)?.total ?? 0 +} + +interface SearchResultsHeaderProps { + search: UniversalSearch + filter: SourceFilter + shownSearch: UniversalSearch + isAiTab: boolean + visibleGroups: SearchGroup[] +} + +const SearchResultsHeader: React.FC = ({ search, filter, shownSearch, isAiTab, visibleGroups }) => +
+

+ {search.isSearchable + // translators: %s: search query. + ? sprintf(__('Search results for “%s”', 'code-snippets'), search.term) + : __('Search', 'code-snippets')} +

+ + {search.isSearchable && !isLockedTab(filter) && !isPending(shownSearch.loadingSources, isAiTab ? 'all' : filter) && ( + {formatResultCount(countResults(visibleGroups))})} +
+ const SearchResultsPage = () => { const [query] = useState(() => fetchQueryParam('s') ?? '') const [tabs] = useState(getSourceTabs) @@ -322,24 +368,6 @@ const SearchResultsPage = () => { ? aiSearch.groups : search.groups.filter(group => 'all' === filter || group.source === filter) - const getTabCount = (tab: SubnavTab): number | undefined => { - if (AI_SEARCH_TAB === tab.name) { - return isAiTab && 0 === aiSearch.loadingSources.length ? aiSearch.total : undefined - } - - if (isLockedTab(tab.name)) { - return undefined - } - - if ('all' === tab.name) { - return 0 === search.loadingSources.length ? search.total : undefined - } - - return search.loadingSources.includes(tab.name) - ? undefined - : search.groups.find(group => group.source === tab.name)?.total ?? 0 - } - return (
{ tabs={tabs} currentTab={currentTab} setCurrentTab={setCurrentTab} - getTabCount={search.isSearchable ? getTabCount : undefined} + getTabCount={search.isSearchable ? tab => getTabCount({ tab, search, aiSearch, isAiTab }) : undefined} queryParamName={SOURCE_QUERY_PARAM} /> -
-

- {search.isSearchable - // translators: %s: search query. - ? sprintf(__('Search results for “%s”', 'code-snippets'), search.term) - : __('Search', 'code-snippets')} -

- - {search.isSearchable && !isLockedTab(filter) && !isPending(shownSearch.loadingSources, isAiTab ? 'all' : filter) && ( - {formatResultCount(countResults(visibleGroups))})} -
+
diff --git a/src/js/components/common/UniversalSearch/UniversalSearch.tsx b/src/js/components/common/UniversalSearch/UniversalSearch.tsx index ddce76db2..eaeb300c5 100644 --- a/src/js/components/common/UniversalSearch/UniversalSearch.tsx +++ b/src/js/components/common/UniversalSearch/UniversalSearch.tsx @@ -234,6 +234,56 @@ interface SearchDropdownProps { isAiLoading: boolean } +type SearchDropdownResultsProps = Pick + +const SearchDropdownResults: React.FC = ({ + listboxId, term, visibleGroups, pendingLabels, activeId, getOptionId, setActiveId, aiGroup, isAiLoading +}) => +
+ {visibleGroups.map(({ group, items }) => + )} + + {pendingLabels.map(label => +
+ + + {label} +
)} + + {aiGroup && ( + {__('AI', 'code-snippets')}{__('AI results', 'code-snippets')}} + viewAllUrl={getSearchPageUrl({ query: term, source: AI_SEARCH_TAB })} + activeId={activeId} + getOptionId={getOptionId} + setActiveId={setActiveId} + />)} + + {isAiLoading && ( +
+ + + {__('Searching with AI…', 'code-snippets')} +
)} +
+ const SearchDropdown: React.FC = ({ listboxId, hidden, @@ -266,49 +316,15 @@ const SearchDropdown: React.FC = ({ sprintf(__('No results for “%s”.', 'code-snippets'), term)}

)} -
- {visibleGroups.map(({ group, items }) => - )} - - {pendingLabels.map(label => -
- - - {label} -
)} - - {aiGroup && ( - {__('AI', 'code-snippets')}{__('AI results', 'code-snippets')}} - viewAllUrl={getSearchPageUrl({ query: term, source: AI_SEARCH_TAB })} - activeId={activeId} - getOptionId={getOptionId} - setActiveId={setActiveId} - />)} - - {isAiLoading && ( -
- - - {__('Searching with AI…', 'code-snippets')} -
)} -
+ @@ -412,16 +428,13 @@ const useDocumentListeners = ({ wrapperRef, onShortcut, onPointerDownOutside }: const isSearchPage = 'search' === fetchQueryParam('subpage') -const SearchBar = () => { - const [query, setQuery] = useState(() => isSearchPage ? fetchQueryParam('s') ?? '' : '') - const [isOpen, setIsOpen] = useState(false) - const [isExpanded, setIsExpanded] = useState(false) - const [selectedScope, setSelectedScope] = useState('all') - const [selectedId, setSelectedId] = useState() - const wrapperRef = useRef(null) - const inputRef = useRef(null) - const baseId = useId() +interface SearchBarDataProps { + query: string + isOpen: boolean + selectedScope: SearchScope +} +const useSearchBarData = ({ query, isOpen, selectedScope }: SearchBarDataProps) => { // The search page lists the results itself, so the bar only searches once the user opens it. const { term, isSearchable, groups, loadingSources, total, hasFailed } = useUniversalSearch({ query, perSource: RESULTS_PER_SOURCE, enabled: isOpen }) @@ -457,40 +470,34 @@ const SearchBar = () => { const visibleGroups = getVisibleGroups(groups, scope) const options = [...visibleGroups, ...aiGroup ? [aiGroup] : []].flatMap(({ items }) => items) - const activeItem = options.find(item => item.id === selectedId) - const activeId = activeItem?.id - const isDropdownOpen = isOpen && isSearchable - const getOptionId = (item: SearchItem) => `${baseId}-${item.id}` - const activeOptionId = activeItem && getOptionId(activeItem) const pendingLabels = loadingSources .filter(source => 'all' === scope || source === scope) .flatMap(source => SEARCH_LOADING_LABELS[source] ?? []) - const close = () => { - setIsOpen(false) - setSelectedId(undefined) + return { + term, isSearchable, groups, loadingSources, total, hasFailed, scope, aiGroup, isAiLoading, visibleGroups, options, pendingLabels } +} - useDocumentListeners({ - wrapperRef, - onShortcut: () => { - setIsExpanded(true) - inputRef.current?.focus() - }, - onPointerDownOutside: () => { - close() - setIsExpanded(false) - } - }) - - // The field is hidden while collapsed, so it can only take focus once expanding has rendered it. - useEffect(() => { - if (isExpanded) { - inputRef.current?.focus() - } - }, [isExpanded]) +interface SearchKeyboardProps { + options: SearchItem[] + activeItem: SearchItem | undefined + getOptionId: (item: SearchItem) => string + setSelectedId: (id: string) => void + setIsOpen: (open: boolean) => void + setIsExpanded: (expanded: boolean) => void + isDropdownOpen: boolean + isSearchable: boolean + term: string + scope: SearchScope + close: VoidFunction +} +const getSearchKeyDownHandler = ({ + options, activeItem, getOptionId, setSelectedId, setIsOpen, setIsExpanded, + isDropdownOpen, isSearchable, term, scope, close +}: SearchKeyboardProps): React.KeyboardEventHandler => { const moveSelection = (step: number) => { if (0 < options.length) { const activeIndex = activeItem ? options.indexOf(activeItem) : -1 @@ -502,7 +509,7 @@ const SearchBar = () => { } } - const handleKeyDown = (event: React.KeyboardEvent) => { + return (event: React.KeyboardEvent) => { switch (event.key) { case 'ArrowDown': case 'ArrowUp': @@ -533,6 +540,122 @@ const SearchBar = () => { break } } +} + +interface SearchFocusProps { + wrapperRef: React.RefObject + inputRef: React.RefObject + isExpanded: boolean + setIsExpanded: (expanded: boolean) => void + close: VoidFunction +} + +const useSearchFocus = ({ wrapperRef, inputRef, isExpanded, setIsExpanded, close }: SearchFocusProps) => { + useDocumentListeners({ + wrapperRef, + onShortcut: () => { + setIsExpanded(true) + inputRef.current?.focus() + }, + onPointerDownOutside: () => { + close() + setIsExpanded(false) + } + }) + + // The field is hidden while collapsed, so it can only take focus once expanding has rendered it. + useEffect(() => { + if (isExpanded) { + inputRef.current?.focus() + } + }, [isExpanded, inputRef]) +} + +const useSearchBar = () => { + const [query, setQuery] = useState(() => isSearchPage ? fetchQueryParam('s') ?? '' : '') + const [isOpen, setIsOpen] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [selectedScope, setSelectedScope] = useState('all') + const [selectedId, setSelectedId] = useState() + const wrapperRef = useRef(null) + const inputRef = useRef(null) + const baseId = useId() + + const data = useSearchBarData({ query, isOpen, selectedScope }) + const { isSearchable, options } = data + const activeItem = options.find(item => item.id === selectedId) + const isDropdownOpen = isOpen && isSearchable + const getOptionId = (item: SearchItem) => `${baseId}-${item.id}` + const activeOptionId = activeItem && getOptionId(activeItem) + + const close = () => { + setIsOpen(false) + setSelectedId(undefined) + } + + useSearchFocus({ wrapperRef, inputRef, isExpanded, setIsExpanded, close }) + + const handleKeyDown = getSearchKeyDownHandler({ + ...data, + activeItem, + getOptionId, + setSelectedId, + setIsOpen, + setIsExpanded, + isDropdownOpen, + close + }) + + return { + data, + wrapperRef, + inputRef, + baseId, + isExpanded, + setIsExpanded, + isDropdownOpen, + activeOptionId, + query, + setQuery, + setSelectedId, + setIsOpen, + handleKeyDown, + close, + setSelectedScope, + activeId: activeItem?.id, + getOptionId + } +} + +const SearchBarField: React.FC, + 'inputRef' | 'query' | 'isDropdownOpen' | 'baseId' | 'activeOptionId' | 'setQuery' | 'setSelectedId' | + 'setIsOpen' | 'handleKeyDown' | 'close' | 'setIsExpanded'>> = ({ + inputRef, query, isDropdownOpen, baseId, activeOptionId, setQuery, setSelectedId, + setIsOpen, handleKeyDown, close, setIsExpanded +}) => + { + setQuery(value) + setSelectedId(undefined) + setIsOpen(true) + }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + onCollapse={() => { + close() + setIsExpanded(false) + }} + /> + +const SearchBarView: React.FC> = props => { + const { data, wrapperRef, baseId, isExpanded, setIsExpanded, isDropdownOpen, + close, setSelectedScope, activeId, getOptionId, setSelectedId } = props + const { term, groups, loadingSources, total, hasFailed, scope, aiGroup, isAiLoading, visibleGroups, pendingLabels } = data return (
{ - { - setQuery(value) - setSelectedId(undefined) - setIsOpen(true) - }} - onFocus={() => setIsOpen(true)} - onKeyDown={handleKeyDown} - onCollapse={() => { - close() - setIsExpanded(false) - }} - /> + {/* The bar is on every screen, so its announcements carry no status role that would compete with the screen's own. */}
@@ -601,6 +707,8 @@ const SearchBar = () => { ) } +const SearchBar = () => + export const UniversalSearch = () => REST_BASES.search ? diff --git a/src/js/hooks/useUniversalSearch.ts b/src/js/hooks/useUniversalSearch.ts index b3c87e4b3..96d34c881 100644 --- a/src/js/hooks/useUniversalSearch.ts +++ b/src/js/hooks/useUniversalSearch.ts @@ -115,26 +115,16 @@ const requestGroups = ({ .then(response => response.data.groups) .catch(() => undefined) -export const useUniversalSearch = ({ - query, - fields = SEARCH_FIELDS, - perSource, - enabled = true, - excerpts = true, - sources = SEARCH_SOURCES, - method = 'term', - delay = DEBOUNCE_MS -}: UseUniversalSearchProps): UniversalSearch => { - const { axiosInstance } = useRestAPI() - const [state, setState] = useState({ requestKey: '', loaded: [], groups: {}, hasFailed: false }) +interface SearchStateProps extends Omit { + requestKey: string + sourceList: string + delay: number +} - const term = query.trim() - const fieldList = fields.join(',') - const sourceList = sources.join(',') - const isSearchable = SEARCH_MIN_LENGTH <= term.length && '' !== fieldList - const requestKey = enabled && isSearchable - ? `${term}|${fieldList}|${perSource}|${excerpts}|${sourceList}|${method}` - : '' +const useSearchState = ({ + axiosInstance, requestKey, sourceList, term, fieldList, perSource, excerpts, method, delay +}: SearchStateProps) => { + const [state, setState] = useState({ requestKey: '', loaded: [], groups: {}, hasFailed: false }) useEffect(() => { if (!requestKey) { @@ -176,6 +166,31 @@ export const useUniversalSearch = ({ } }, [axiosInstance, requestKey, term, fieldList, perSource, excerpts, sourceList, method, delay]) + return state +} + +export const useUniversalSearch = ({ + query, + fields = SEARCH_FIELDS, + perSource, + enabled = true, + excerpts = true, + sources = SEARCH_SOURCES, + method = 'term', + delay = DEBOUNCE_MS +}: UseUniversalSearchProps): UniversalSearch => { + const { axiosInstance } = useRestAPI() + + const term = query.trim() + const fieldList = fields.join(',') + const sourceList = sources.join(',') + const isSearchable = SEARCH_MIN_LENGTH <= term.length && '' !== fieldList + const requestKey = enabled && isSearchable + ? `${term}|${fieldList}|${perSource}|${excerpts}|${sourceList}|${method}` + : '' + + const state = useSearchState({ axiosInstance, requestKey, sourceList, term, fieldList, perSource, excerpts, method, delay }) + return useMemo(() => { if (!requestKey) { return { term, isSearchable, groups: [], loadingSources: [], total: 0, hasFailed: false }