From 376d4022ec5e22ef2d4d5e10c2a0f18f965bd6dd Mon Sep 17 00:00:00 2001 From: Dave Falke Date: Thu, 7 Sep 2023 13:47:08 -0400 Subject: [PATCH 1/2] Add search history menu to site search input --- .../src/components/SiteSearch/SiteSearch.scss | 13 +-- .../components/SiteSearch/SiteSearchHooks.ts | 16 ++++ .../components/SiteSearch/SiteSearchInput.tsx | 34 +++++++- .../components/SiteSearch/TypeAheadInput.tsx | 83 ++++++++++++++++--- 4 files changed, 126 insertions(+), 20 deletions(-) create mode 100644 packages/libs/web-common/src/components/SiteSearch/SiteSearchHooks.ts diff --git a/packages/libs/web-common/src/components/SiteSearch/SiteSearch.scss b/packages/libs/web-common/src/components/SiteSearch/SiteSearch.scss index 96b14c5c1b..31d457fe41 100644 --- a/packages/libs/web-common/src/components/SiteSearch/SiteSearch.scss +++ b/packages/libs/web-common/src/components/SiteSearch/SiteSearch.scss @@ -157,10 +157,13 @@ padding: 0.2em 0 0.2em 1em; text-align: left; - &:focus, - &:active, - &:hover { - background: #dedede; + &[tabindex] { + cursor: pointer; + &:focus, + &:active, + &:hover { + background: #dedede; + } } &:first-child { @@ -184,7 +187,7 @@ } } - button { + > button { border: none; border-radius: 0; background: #6c757d; diff --git a/packages/libs/web-common/src/components/SiteSearch/SiteSearchHooks.ts b/packages/libs/web-common/src/components/SiteSearch/SiteSearchHooks.ts new file mode 100644 index 0000000000..7b3381e70f --- /dev/null +++ b/packages/libs/web-common/src/components/SiteSearch/SiteSearchHooks.ts @@ -0,0 +1,16 @@ +import { useStorageBackedState } from '@veupathdb/wdk-client/lib/Hooks/StorageBackedState'; +import { + arrayOf, + decodeOrElse, + string, +} from '@veupathdb/wdk-client/lib/Utils/Json'; + +export function useRecentSearches() { + return useStorageBackedState( + window.localStorage, + [], + 'site-search/history', + JSON.stringify, + (value) => decodeOrElse(arrayOf(string), [], value) + ); +} diff --git a/packages/libs/web-common/src/components/SiteSearch/SiteSearchInput.tsx b/packages/libs/web-common/src/components/SiteSearch/SiteSearchInput.tsx index cf668f80c3..f0e95f0d88 100644 --- a/packages/libs/web-common/src/components/SiteSearch/SiteSearchInput.tsx +++ b/packages/libs/web-common/src/components/SiteSearch/SiteSearchInput.tsx @@ -1,4 +1,4 @@ -import { isEmpty } from 'lodash'; +import { isEmpty, uniq } from 'lodash'; import React, { useCallback, useEffect, useRef } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { Tooltip } from '@veupathdb/wdk-client/lib/Components'; @@ -14,9 +14,10 @@ import { ORGANISM_PARAM, FILTERS_PARAM, } from './SiteSearchConstants'; +import { TypeAheadInput } from './TypeAheadInput'; +import { useRecentSearches } from './SiteSearchHooks'; import './SiteSearch.scss'; -import { TypeAheadInput } from './TypeAheadInput'; const cx = makeClassNameHelper('SiteSearch'); @@ -58,6 +59,8 @@ export const SiteSearchInput = wrappable(function ({ const hasFilters = !isEmpty(docType) || !isEmpty(organisms) || !isEmpty(fields); + const [recentSearches, setRecentSearches] = useRecentSearches(); + const onSearch = useCallback( (queryString: string) => { history.push(`${SITE_SEARCH_ROUTE}?${queryString}`); @@ -65,20 +68,40 @@ export const SiteSearchInput = wrappable(function ({ [history] ); + const saveSearchString = useCallback(() => { + if (inputRef.current?.value) { + setRecentSearches(uniq([inputRef.current.value].concat(recentSearches))); + } + }, [setRecentSearches, recentSearches]); + const handleSubmitWithFilters = useCallback(() => { const { current } = formRef; if (current == null) return; const formData = new FormData(current); const queryString = new URLSearchParams(formData as any).toString(); onSearch(queryString); - }, [onSearch]); + saveSearchString(); + }, [onSearch, saveSearchString]); const handleSubmitWithoutFilters = useCallback(() => { const queryString = `q=${encodeURIComponent( inputRef.current?.value || '' )}`; onSearch(queryString); - }, [onSearch]); + saveSearchString(); + }, [onSearch, saveSearchString]); + + const handleSubmitWithRecentSearch = useCallback( + (searchString: string) => { + const queryString = `q=${encodeURIComponent(searchString)}`; + onSearch(queryString); + }, + [onSearch] + ); + + const clearRecentSearches = useCallback(() => { + setRecentSearches([]); + }, [setRecentSearches]); const [lastSearchQueryString, setLastSearchQueryString] = useSessionBackedState( @@ -132,6 +155,9 @@ export const SiteSearchInput = wrappable(function ({ inputReference={inputRef} searchString={searchString} placeHolderText={placeholderText} + recentSearches={recentSearches} + onRecentSearchSelect={handleSubmitWithRecentSearch} + onClearRecentSearches={clearRecentSearches} /> {location.pathname !== SITE_SEARCH_ROUTE && lastSearchQueryString && ( diff --git a/packages/libs/web-common/src/components/SiteSearch/TypeAheadInput.tsx b/packages/libs/web-common/src/components/SiteSearch/TypeAheadInput.tsx index e59a7f1a29..bb8cf38d7e 100644 --- a/packages/libs/web-common/src/components/SiteSearch/TypeAheadInput.tsx +++ b/packages/libs/web-common/src/components/SiteSearch/TypeAheadInput.tsx @@ -8,6 +8,7 @@ import { FetchClient, ioTransformer, } from '@veupathdb/http-utils'; +import { useUITheme } from '@veupathdb/coreui/lib/components/theming'; // region Keyboard @@ -224,12 +225,17 @@ export interface TypeAheadInputProps { readonly inputReference: React.RefObject; readonly searchString: string; readonly placeHolderText?: string; + readonly recentSearches: string[]; + readonly onRecentSearchSelect: (recentSearch: string) => void; + readonly onClearRecentSearches: () => void; } export function TypeAheadInput(props: TypeAheadInputProps): JSX.Element { const [suggestions, setSuggestions] = useState>([]); const [hintValue, setHintValue] = useState(''); const [inputValue, setInputValue] = useState(props.searchString); + // "focus" follows mouse clicks, but not tabbing + const [hasFocus, setHasFocus] = useState(false); const typeAheadAPI = new TypeAheadAPI({ baseUrl: ((ep) => (ep.endsWith('/') ? ep : ep + '/') + TYPEAHEAD_PATH)( @@ -237,6 +243,7 @@ export function TypeAheadInput(props: TypeAheadInputProps): JSX.Element { ), }); const ulReference = useRef(null); + const containerRef = useRef(null); const ulClassName = suggestions.length == 0 ? 'type-ahead-hints hidden' : 'type-ahead-hints'; @@ -422,6 +429,11 @@ export function TypeAheadInput(props: TypeAheadInputProps): JSX.Element { else if (kbIsEscape(e)) { resetInput(); } + + // If an item is selected, remove "focus" + else if (kbIsEnter(e)) { + setHasFocus(false); + } }; const typeAhead = debounce((fn: () => string) => { @@ -440,6 +452,16 @@ export function TypeAheadInput(props: TypeAheadInputProps): JSX.Element { if (lastWordOf(element.value).length >= 3) typeAhead(() => element.value); }; + const selectRecentSearch = (recentSearch: string) => { + props.onRecentSearchSelect(recentSearch); + setInputValue(recentSearch); + setHasFocus(false); + }; + + // Show history if input is empty, or if input matches the term in the url + const showHistory = + hasFocus && (inputValue === '' || props.searchString === inputValue); + const suggestionItems = suggestions.map((suggestion) => (
  • )); - const clickHandler = (e: MouseEvent) => { - if ( - e.target instanceof HTMLElement && - e.target.parentElement !== ulReference.current - ) - setSuggestions([]); - }; + const historyMenu = props.recentSearches.length + ? [ +
  • + Your recent searches +
  • , + ...props.recentSearches.map((recentSearch) => ( +
  • selectRecentSearch(recentSearch)} + onKeyDown={(e) => { + if (kbIsEnter(e)) { + selectRecentSearch(recentSearch); + } + }} + > + {recentSearch} +
  • + )), +
  • { + if (kbIsEnter(e)) { + props.onClearRecentSearches(); + } + }} + > + Clear search history +
  • , + ] + : null; useEffect(() => { + const clickHandler = (e: MouseEvent) => { + if (!(e.target instanceof HTMLElement)) return; + if (e.target.parentElement !== ulReference.current) { + setSuggestions([]); + } + if (!containerRef.current?.contains(e.target)) { + setHasFocus(false); + } + }; + document.addEventListener('click', clickHandler); - return () => removeEventListener('click', clickHandler); + return () => { + removeEventListener('click', clickHandler); + }; }, []); return ( -
    +
    setHasFocus(true)} />
      - {suggestionItems} + {showHistory ? historyMenu : suggestionItems}
    ); From 9f6243072930d684bc0a9040f552706a4655c1c1 Mon Sep 17 00:00:00 2001 From: Dave Falke Date: Thu, 7 Sep 2023 13:55:06 -0400 Subject: [PATCH 2/2] Only save up to 10 searches --- .../web-common/src/components/SiteSearch/SiteSearchInput.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/libs/web-common/src/components/SiteSearch/SiteSearchInput.tsx b/packages/libs/web-common/src/components/SiteSearch/SiteSearchInput.tsx index f0e95f0d88..d922e8526a 100644 --- a/packages/libs/web-common/src/components/SiteSearch/SiteSearchInput.tsx +++ b/packages/libs/web-common/src/components/SiteSearch/SiteSearchInput.tsx @@ -70,7 +70,9 @@ export const SiteSearchInput = wrappable(function ({ const saveSearchString = useCallback(() => { if (inputRef.current?.value) { - setRecentSearches(uniq([inputRef.current.value].concat(recentSearches))); + setRecentSearches( + uniq([inputRef.current.value].concat(recentSearches)).slice(0, 10) + ); } }, [setRecentSearches, recentSearches]);