UNPKG

@navinc/base-react-components

Version:
365 lines (318 loc) 9.39 kB
import React, { useEffect, useRef, useState } from 'react' import styled from 'styled-components' import LoadingDots from './loading-dots.js' import Icon from './icon.js' import { Err, Errors, Field, FieldWrapper, Helper, Input, Label } from './form-elements/shared.js' import Copy from './copy.js' import { focusWithoutScroll, capitalize } from '@navinc/utils' import { useDebouncedCallback } from 'use-debounce' import PropTypes from 'prop-types' import isRebrand from './is-rebrand.js' const id = (id) => id const SearchInputContainer = styled.div` width: 100%; ` const ResultsContainer = styled.div` position: relative; z-index: 2; & > ${Helper} { margin-left: ${({ theme }) => theme.gu(2)}; } ` const getResultContainerBackgroundColor = (isActive, theme) => { if (isRebrand(theme) && isActive) return `background-color: ${theme.navNeutral100}; border: 4px solid ${theme.navStatusPositive500}` return isActive && `background-color: ${theme.neutral100};` } const ResultContainer = styled.div` cursor: pointer; outline: none; padding: 16px; border-bottom: 1px solid ${({ theme }) => (isRebrand(theme) ? theme.navNeutral300 : theme.neutral300)}; ${({ isActive, theme }) => getResultContainerBackgroundColor(isActive, theme)}; &:hover { background-color: ${({ theme }) => (isRebrand(theme) ? theme.navNeutral100 : theme.neutral100)}; } &:focus { outline: ${({ theme }) => theme.focusOutline}; } ` const Results = styled.div` background-color: ${({ theme }) => (isRebrand(theme) ? theme.navSecondary100 : theme.white)}; border: solid 1px ${({ theme }) => (isRebrand(theme) ? theme.navNeutral300 : theme.neutral300)}; border-top: none; border-radius: ${({ theme }) => (isRebrand(theme) ? '0 0 8px 8px' : '0 0 4px 4px')}; position: ${({ shouldPositionResultsRelative }) => (shouldPositionResultsRelative ? 'relative' : 'absolute')}; top: 0; overflow-y: auto; max-height: ${({ resultsMaxHeight }) => resultsMaxHeight || '300px'}; width: 100%; /* prettier-ignore */ ${ResultContainer}:last-child { border-bottom: none; } ` const StyledFieldWrapper = styled(FieldWrapper)` & ${Icon}, & ${LoadingDots} { position: absolute; right: 16px; top: 50%; transform: translateY(-50%); } & ${Icon} { width: 24px; } & ${LoadingDots} { color: ${({ theme }) => theme.navPrimary}; width: 56px; } & ${Input} { text-overflow: ellipsis; padding-right: 40px; ${({ shouldShowDropDown }) => shouldShowDropDown && 'border-radius: 4px 4px 0 0;'} } ` const StyledNoResults = styled.div` align-items: center; display: flex; flex-direction: column; margin-bottom: 24px; margin-top: 24px; ` const NoResultsIcon = styled(Icon)` color: ${({ theme }) => (isRebrand(theme) ? theme.navNeutral400 : theme.neutral400)}; margin-bottom: 8px; ` const DefaultNoResults = ({ query }) => ( <StyledNoResults> <NoResultsIcon name="actions/circle-faq" /> <Copy>No results for "{query}"</Copy> </StyledNoResults> ) export const SearchInput = ({ autoFocus, className, errors, helperText, isLoading, isInvalid, label, lede, name, NoResults, onBlur, onChange, onFocus, Result, required, results, resultsMaxHeight, resultToQuery, search, shouldPositionResultsRelative, touched, type, value, ...inputProps }) => { const [focusedResultIndex, setFocusedResultIndex] = useState(-1) const [canBeOpen, setCanBeOpen] = useState(false) const [query, setQuery] = useState('') const [lastSearchedQuery, setLastSearchedQuery] = useState('') const inputRef = useRef(null) const resultsRef = useRef(null) const shouldShowDropDown = !!(query.length && canBeOpen && query === lastSearchedQuery && !isLoading) const shouldShowResults = !!(shouldShowDropDown && results.length) const shouldShowNoResults = !!(shouldShowDropDown && !results.length) const RenderNoResults = NoResults || DefaultNoResults const isVisited = touched || value useEffect(() => { resultToQuery(value) && setQuery(resultToQuery(value) || '') }, [resultToQuery, value]) useEffect(() => { if (autoFocus) { focusWithoutScroll(inputRef.current) } }, [autoFocus]) const searchDebounced = useDebouncedCallback((query) => { setLastSearchedQuery(query) search(query) setFocusedResultIndex(-1) }, 500) const handleChange = ({ target = {} } = {}) => { const newQuery = target.value ?? '' setQuery(newQuery) onChange({ target: { name, value: null, }, }) if (newQuery.length > 0) { searchDebounced(newQuery) setCanBeOpen(true) } } const handleInputFocus = (event) => { setCanBeOpen(true) onFocus(event) } const focusResult = (index) => { const ref = resultsRef.current?.children?.[index] if (ref) { setFocusedResultIndex(index) ref.focus() inputRef.current.focus() } } const goToNextResult = () => { const newIndex = focusedResultIndex + 1 if (newIndex < results.length) { focusResult(newIndex) } } const goToPreviousResult = () => { const newIndex = focusedResultIndex - 1 if (newIndex > -2) { focusResult(newIndex) } } const closeResults = () => { setCanBeOpen(false) setFocusedResultIndex(-1) } const onSelectResult = (clickedResult) => { const result = clickedResult || results[focusedResultIndex] closeResults() onChange({ target: { name, value: result, }, }) } const handleBlurOnInput = (event) => { if (!resultsRef.current || (resultsRef.current && !resultsRef.current.contains(event.relatedTarget))) { closeResults() onBlur(event) } } const handleKeyDownOnInput = (event) => { switch (event.keyCode) { case 40: // Down Arrow if (shouldShowResults) { event.preventDefault() goToNextResult() } else { setCanBeOpen(true) } break case 9: // Tab if (shouldShowResults) { event.preventDefault() if (event.shiftKey) { goToPreviousResult() } else { goToNextResult() } } break case 38: // Up Arrow if (shouldShowResults) { event.preventDefault() goToPreviousResult() } break case 27: // Escape if (focusedResultIndex > -1) { setFocusedResultIndex(-1) } else { setQuery('') } break case 13: // Enter if (focusedResultIndex > -1) { event.preventDefault() onSelectResult() } break } } return ( <SearchInputContainer className={className}> <StyledFieldWrapper shouldShowDropDown={shouldShowDropDown}> {lede && <Copy bold>{lede}</Copy>} <Field isInvalid={isInvalid} isVisited={isVisited} required={required} type={type}> <Input autoComplete="off" data-testid="search-input:input" name={name} onBlur={handleBlurOnInput} onChange={handleChange} onFocus={handleInputFocus} onKeyDown={handleKeyDownOnInput} type={type} ref={inputRef} required={required} value={query} isInvalid={isInvalid} {...inputProps} /> {isLoading ? <LoadingDots /> : <Icon name="system/search" />} <Label required={required} value={query}> {capitalize(label)} </Label> </Field> </StyledFieldWrapper> <ResultsContainer> {shouldShowDropDown && ( <Results resultsMaxHeight={resultsMaxHeight} ref={resultsRef} shouldPositionResultsRelative={shouldPositionResultsRelative} > {shouldShowNoResults && <RenderNoResults query={query} />} {shouldShowResults && results.map((result, index) => ( <ResultContainer isActive={focusedResultIndex === index} key={JSON.stringify(result)} onMouseDown={() => onSelectResult(result)} tabIndex={0} > <Result result={result} /> </ResultContainer> ))} </Results> )} {helperText && <Helper hasSpaceForHelper helperText={helperText} />} <Errors hasSpaceForErrors> {!!errors.length && errors.map((err, i) => <Err key={`err-${i}`}>{err}</Err>)} </Errors> </ResultsContainer> </SearchInputContainer> ) } SearchInput.defaultProps = { errors: [], onBlur: id, onChange: id, onFocus: id, results: [], resultToQuery: id, search: id, } SearchInput.propTypes = { errors: PropTypes.array, isInvalid: PropTypes.bool, NoResults: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), onBlur: PropTypes.func, onChange: PropTypes.func, onFocus: PropTypes.func, results: PropTypes.array, resultsMaxHeight: PropTypes.string, resultToQuery: PropTypes.func.isRequired, Result: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, search: PropTypes.func.isRequired, } export default styled(SearchInput)``