diff --git a/.gitignore b/.gitignore index 529cfe1c88..1221725994 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,7 @@ dist !.yarn/releases !.yarn/sdks !.yarn/versions + + +# IDEs +.idea/ diff --git a/packages/libs/web-common/src/App/Header/HeaderNav.jsx b/packages/libs/web-common/src/App/Header/HeaderNav.jsx index f2eb3cd22a..3247b84f1e 100755 --- a/packages/libs/web-common/src/App/Header/HeaderNav.jsx +++ b/packages/libs/web-common/src/App/Header/HeaderNav.jsx @@ -92,7 +92,7 @@ class HeaderNav extends React.Component { maxWidth: '35em', }} > - + )} @@ -212,7 +212,7 @@ class HeaderNav extends React.Component { fontSize: '1.2em', }} > - + )}
diff --git a/packages/libs/web-common/src/components/SiteSearch/SiteSearch.scss b/packages/libs/web-common/src/components/SiteSearch/SiteSearch.scss index 6872244c56..31d457fe41 100644 --- a/packages/libs/web-common/src/components/SiteSearch/SiteSearch.scss +++ b/packages/libs/web-common/src/components/SiteSearch/SiteSearch.scss @@ -94,23 +94,100 @@ display: flex; justify-content: space-between; border-radius: 1.5em; - overflow: hidden; + position: relative; + + > :first-child { + border-radius: 1.5em 0 0 1.5em; + overflow: hidden; + } + + > :last-child { + border-radius: 0 1.5em 1.5em 0; + overflow: hidden; + } &:focus-within { box-shadow: 0 0 3pt 2pt #0067f4; } - input { - border: none !important; - border-radius: 0; - padding: 0.4em 0.9em !important; + div.type-ahead { width: 100%; - &:focus { - outline: none; + + div.type-ahead-input { + position: relative; + + input { + border: none !important; + padding: 0.4em 0.9em !important; + width: 100%; + + &:focus { + outline: none; + } + } + + input:first-child { + position: relative; + background: #00000000 !important; + z-index: 1; + } + + input:nth-child(2) { + position: absolute; + top: 0; + left: 0; + z-index: 0; + color: gray; + } + } + + ul.type-ahead-hints { + margin: 0.2em 0 0 0; + padding: 0; + list-style: none; + position: absolute; + background: #ededed; + overflow: hidden; + border-radius: 1em; + box-shadow: 0 0 1em rgba(0, 48, 76, 0.5); + left: 0; + right: 0; + + li.type-ahead-hint { + padding: 0.2em 0 0.2em 1em; + text-align: left; + + &[tabindex] { + cursor: pointer; + &:focus, + &:active, + &:hover { + background: #dedede; + } + } + + &:first-child { + padding-top: 0.4em; + } + + &:last-child { + padding-bottom: 0.4em; + } + } } } - button { + &__with-filters { + div.type-ahead { + ul.type-ahead-hints { + li.type-ahead-hint { + padding-left: 13ex; + } + } + } + } + + > 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 4af2d92e4c..d922e8526a 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,6 +14,8 @@ import { ORGANISM_PARAM, FILTERS_PARAM, } from './SiteSearchConstants'; +import { TypeAheadInput } from './TypeAheadInput'; +import { useRecentSearches } from './SiteSearchHooks'; import './SiteSearch.scss'; @@ -26,9 +28,13 @@ const preventEventWith = (callback: () => void) => (event: React.FormEvent) => { export interface Props { placeholderText?: string; + siteSearchURL: string; } -export const SiteSearchInput = wrappable(function ({ placeholderText }: Props) { +export const SiteSearchInput = wrappable(function ({ + placeholderText, + siteSearchURL, +}: Props) { const location = useLocation(); const history = useHistory(); const inputRef = useRef(null); @@ -53,6 +59,8 @@ export const SiteSearchInput = wrappable(function ({ placeholderText }: Props) { const hasFilters = !isEmpty(docType) || !isEmpty(organisms) || !isEmpty(fields); + const [recentSearches, setRecentSearches] = useRecentSearches(); + const onSearch = useCallback( (queryString: string) => { history.push(`${SITE_SEARCH_ROUTE}?${queryString}`); @@ -60,20 +68,42 @@ export const SiteSearchInput = wrappable(function ({ placeholderText }: Props) { [history] ); + const saveSearchString = useCallback(() => { + if (inputRef.current?.value) { + setRecentSearches( + uniq([inputRef.current.value].concat(recentSearches)).slice(0, 10) + ); + } + }, [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( @@ -93,9 +123,21 @@ export const SiteSearchInput = wrappable(function ({ placeholderText }: Props) {
+ {hasFilters ? ( + + + + ) : null} {docType && ( )} @@ -110,25 +152,14 @@ export const SiteSearchInput = wrappable(function ({ placeholderText }: Props) { {fields.map((field) => ( ))} - {hasFilters ? ( - - - - ) : null} - e.target.select()} - name={SEARCH_TERM_PARAM} - key={searchString} - defaultValue={searchString} - placeholder={placeholderText} + {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 new file mode 100644 index 0000000000..bb8cf38d7e --- /dev/null +++ b/packages/libs/web-common/src/components/SiteSearch/TypeAheadInput.tsx @@ -0,0 +1,554 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { SEARCH_TERM_PARAM } from './SiteSearchConstants'; +import * as io from 'io-ts'; + +import './SiteSearch.scss'; +import { + createJsonRequest, + FetchClient, + ioTransformer, +} from '@veupathdb/http-utils'; +import { useUITheme } from '@veupathdb/coreui/lib/components/theming'; + +// region Keyboard + +// +// Keyboard Event Helper Functions. +// + +/** + * Tests whether the keyboard event had a modifier key press present. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event had a modifier key press present, + * otherwise `false`. + */ +const kbHasModifier = (e: React.KeyboardEvent) => + e.altKey || e.metaKey || e.ctrlKey || e.shiftKey; + +/** + * Tests whether the keyboard event had ONLY the "shift" modifier key press + * present. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event had the "shift" modifier and only the + * "shift" modifier press present, otherwise `false`. + */ +const kbHasOnlyShiftMod = (e: React.KeyboardEvent) => + !(e.altKey || e.metaKey || e.ctrlKey) && e.shiftKey; + +/** + * Tests whether the keyboard event was for a "Tab" key press. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event was for a "Tab" key press, otherwise + * `false`. + */ +const kbIsTab = (e: React.KeyboardEvent) => + e.code === 'Tab' || e.keyCode == 9; + +/** + * Tests whether the keyboard event was for a "Space" key press. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event was for a "Space" key press, otherwise + * `false`. + */ +const kbIsSpace = (e: React.KeyboardEvent) => + e.code === 'Space' || e.keyCode == 32; + +/** + * Tests whether the keyboard event was for an "ArrowRight" key press. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event was for an "ArrowRight" key press, + * otherwise `false`. + */ +const kbIsArrowRight = (e: React.KeyboardEvent) => + e.code === 'ArrowRight' || e.keyCode == 39; + +/** + * Tests whether the keyboard event was for an "ArrowDown" key press. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event was for an "ArrowDown" key press, + * otherwise `false`. + */ +const kbIsArrowDown = (e: React.KeyboardEvent) => + e.code === 'ArrowDown' || e.keyCode == 40; + +/** + * Tests whether the keyboard event was for an "ArrowUp" key press. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event was for an "ArrowUp" key press, + * otherwise `false`. + */ +const kbIsArrowUp = (e: React.KeyboardEvent) => + e.code === 'ArrowUp' || e.keyCode == 38; + +/** + * Tests whether the keyboard event was for an "Enter" key press. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event was for an "Enter" key press, otherwise + * `false`. + */ +const kbIsEnter = (e: React.KeyboardEvent) => + e.code === 'Enter' || e.keyCode == 13; + +/** + * Tests whether the keyboard event was for an "Escape" key press. + * + * @param e Keyboard event to test. + * + * @return `true` if the keyboard event was for an "Escape" key press, otherwise + * `false`. + */ +const kbIsEscape = (e: React.KeyboardEvent) => + e.code === 'Escape' || e.keyCode == 27; + +// endregion Keyboard + +// region Strings + +// +// String Helper Functions +// + +/** + * Returns the last word of the given string. + * + * @param value String from which the last word should be returned. + * + * @return The last word of the given string. + */ +const lastWordOf = (value: string) => + ((arr: Array) => (arr.length > 0 ? arr[arr.length - 1] : ''))( + value.split(/ +/) + ); + +/** + * Replaces the last word in the given `original` string with the given + * `replacement` word(s), returning the new string. + * + * @param original Original string whose last word should be replaced. + * + * @param replacement Word that will replace the last word of the original + * string. + * + * @return A new string formed by replacing the last word of the `original` + * string with the given `replacement`. + */ +const replaceLastWord = (original: string, replacement: string) => + original.substring(0, original.lastIndexOf(lastWordOf(original))) + + replacement; + +// endregion Strings + +// region Debouncer + +/** + * Debouncer Function Type. + * + * Represents a function that may be used to build a "debounced" or "debouncing" + * function by passing in a target function that takes a value of type `T` and + * returns nothing. + * + * The return value of a Debouncer function call will be a new function that + * wraps the given target function with debouncing. + * + * @param T Type of the value consumed by the function wrapped by and returned + * by the Debouncer function. + */ +type Debouncer = (func: (value: T) => void) => (value: T) => void; + +/** + * Builds a new `Debouncer` function that may be used to build one or more + * functions that debounce on the same timer. + * + * @param delay Debouncing delay. + * + * @return A `Debouncer` function that may be used to build one or more + * functions that debounce on the same timer. + */ +function buildDebouncer(delay: number): Debouncer { + let timer: any; + + return (func: (value: T) => any) => { + return (value: T) => { + clearTimeout(timer); + timer = setTimeout(() => func(value), delay); + }; + }; +} + +// endregion Debouncer + +// region TypeAheadAPI + +const TYPEAHEAD_PATH: string = 'suggest'; + +const TypeAheadResponse = io.array(io.string); + +/** + * Wrapper for the SiteSearch Type-Ahead HTTP API. + */ +class TypeAheadAPI extends FetchClient { + typeAhead(query: string, cb: (values: Array) => any) { + this.fetch( + createJsonRequest({ + method: 'GET', + path: '?searchText=' + encodeURIComponent(query), + transformResponse: ioTransformer(TypeAheadResponse), + }) + ).then(cb); + } +} + +// endregion TypeAheadAPI + +// region TypeAheadInput + +const debounce = buildDebouncer<() => string>(250); + +export interface TypeAheadInputProps { + readonly siteSearchURL: string; + 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)( + props.siteSearchURL + ), + }); + const ulReference = useRef(null); + const containerRef = useRef(null); + const ulClassName = + suggestions.length == 0 ? 'type-ahead-hints hidden' : 'type-ahead-hints'; + + /** + * Updates the "suggestion" input with the given hint text value. + * + * @param hint Hint text to show. + */ + const showHint = (hint: string) => { + if (inputValue.length == 0) { + setHintValue(hint); + } else if (hint.startsWith(lastWordOf(inputValue))) { + setHintValue(replaceLastWord(inputValue, hint)); + } else { + setHintValue(inputValue + ' ' + hint); + } + }; + + /** + * Refocuses the user input field and clears the suggestions, "resetting" the + * state of the form. + */ + const resetInput = () => { + props.inputReference.current?.focus(); + setSuggestions([]); + }; + + /** + * Resets the "suggestion" input with the value pulled from the user input + * field. + */ + const resetHint = () => { + setHintValue(inputValue); + }; + + /** + * "Selects" the suggested hint by updating the user input value to the value + * of the "suggestion" input. + */ + const selectHint = () => { + setInputValue(hintValue + ' '); + resetInput(); + }; + + /** + * Handles keyboard events on elements of the suggestion list. + * + * @param e Keyboard event to handle. + * + * @param suggestion Value of the suggestion element that the event originated + * from. + */ + const onLiKeyDown = ( + e: React.KeyboardEvent, + suggestion: string + ) => { + // Filter keyboard events with modifiers: + if (kbHasModifier(e)) { + // If the event was specifically a + combination then we want + // to reverse the focus by one element, either selecting the suggestion + // above the event source, or if the event source was the first + // suggestion, selecting the user input field itself. + if (kbHasOnlyShiftMod(e) && kbIsTab(e)) { + e.preventDefault(); + e.stopPropagation(); + + if (ulReference.current?.firstElementChild === e.currentTarget) { + props.inputReference.current?.focus(); + } else { + ( + e.currentTarget.previousElementSibling as HTMLLIElement | null + )?.focus(); + } + } + + // If the event was anything other than a + and had one or + // more modifier keys pressed, then we will disregard it. + else { + return; + } + } + + // If the event was a or key press then select the hint + // that was the source of the event. Additionally, prevent the default + // behavior of the action as otherwise we will create an extra space + // character in the user input field or scroll the page to the right. + else if (kbIsSpace(e) || kbIsArrowRight(e)) { + e.preventDefault(); + e.stopPropagation(); + selectHint(); + } + + // If the event was an key press, then select the hint that was the + // source of the event. We DO NOT prevent the default behavior here as the + // key-up will cause the SiteSearch form to be submitted which is + // the desired behavior. + else if (kbIsEnter(e)) { + selectHint(); + } + + // If the event was an key press, then attempt to shift the focus + // to the element above the event source element. If the event source + // element was the first suggestion item in the list, then the focus should + // be returned to the user input. + // + // Additionally, prevent the default behavior here as the up arrow will + // attempt to scroll the view up. + else if (kbIsArrowUp(e)) { + e.preventDefault(); + e.stopPropagation(); + if (ulReference.current?.firstElementChild === e.currentTarget) { + props.inputReference.current?.focus(); + } else { + ( + e.currentTarget.previousElementSibling as HTMLLIElement | null + )?.focus(); + } + } + + // If the event was an or key press, then attempt to shift + // the focus to the element below the event source element. If the event + // source element was the last suggestion item in the list, then the focus + // should be returned to the top of the suggestion list. + // + // Additionally, prevent the default behavior here as the down arrow will + // attempt to scroll the view down, and the tab key will shift focus to + // whatever the next focus-able element on the page is. + else if (kbIsArrowDown(e) || kbIsTab(e)) { + e.preventDefault(); + e.stopPropagation(); + + if (ulReference.current?.lastElementChild === e.currentTarget) { + ( + ulReference.current?.firstElementChild as HTMLLIElement | null + )?.focus(); + } else { + (e.currentTarget.nextElementSibling as HTMLLIElement | null)?.focus(); + } + } + + // If the event was an key press, "reset" the form. + else if (kbIsEscape(e)) { + resetInput(); + } + }; + + /** + * Handles keyboard events on the user input element. + * + * @param e Keyboard event to handle. + */ + const onInputKeyDown = (e: React.KeyboardEvent) => { + // If the keyboard event has a modifier on it, ignore it. + // TODO: on + should we roll the focus back to the last + // suggestion (provided there is such a suggestion)? + if (kbHasModifier(e)) { + return; + } + + // If the event is an key press, attempt to focus the first + // suggestion (if such a suggestion exists). + // + // Additionally, prevent the default behavior of the event as it will + // attempt to scroll the view down. + else if (kbIsArrowDown(e)) { + e.preventDefault(); + e.stopPropagation(); + (ulReference.current?.firstElementChild as HTMLLIElement | null)?.focus(); + } + + // If the event is an key press, attempt to focus the last + // suggestion (if such a suggestion exists). + // + // Additionally, prevent the default behavior of the event as it will + // attempt to scroll the view up. + else if (kbIsArrowUp(e)) { + e.preventDefault(); + e.stopPropagation(); + (ulReference.current?.lastElementChild as HTMLLIElement | null)?.focus(); + } + + // If the event was an key press, "reset" the form. + else if (kbIsEscape(e)) { + resetInput(); + } + + // If an item is selected, remove "focus" + else if (kbIsEnter(e)) { + setHasFocus(false); + } + }; + + const typeAhead = debounce((fn: () => string) => { + const value = fn(); + if (lastWordOf(value).length >= 3) + typeAheadAPI.typeAhead(lastWordOf(value), setSuggestions); + }); + + const onInputChange = (e: React.ChangeEvent) => { + const element = e.currentTarget; + + setInputValue(element.value); + setHintValue(element.value); + setSuggestions([]); + + 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) => ( +
  • onLiKeyDown(e, suggestion)} + onFocus={() => showHint(suggestion)} + onBlur={resetHint} + onClick={selectHint} + > + {suggestion} +
  • + )); + + 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 ( +
    +
    + setHasFocus(true)} + /> + +
    +
      + {showHistory ? historyMenu : suggestionItems} +
    +
    + ); +} + +// endregion TypeAheadInput diff --git a/packages/libs/web-common/src/components/homepage/Header.tsx b/packages/libs/web-common/src/components/homepage/Header.tsx index 8af802ef55..b737ff1859 100644 --- a/packages/libs/web-common/src/components/homepage/Header.tsx +++ b/packages/libs/web-common/src/components/homepage/Header.tsx @@ -22,7 +22,8 @@ import { User } from '@veupathdb/wdk-client/lib/Utils/WdkUser'; import UserMenu from '../../App/UserMenu'; import { SocialMediaLinks } from '../../components/homepage/SocialMediaLinks'; -import { SiteSearchInput } from '../../components/SiteSearch/SiteSearchInput'; +import { SiteSearchInput } from '../SiteSearch/SiteSearchInput'; +import { SITE_SEARCH_ROUTE } from '../SiteSearch/SiteSearchConstants'; import { webAppUrl } from '../../config'; @@ -194,7 +195,7 @@ const HeaderView = withRouter( dismissSubmenus={dismissSubmenus} />
    - + ) { return function () { @@ -24,6 +25,11 @@ export function SiteSearchInput(DefaultComponent: React.ComponentType) { return 'Site search, e.g. ' + examples; }, []); - return ; + return ( + + ); }; }