@navinc/base-react-components
Version:
Nav's Pattern Library
365 lines (318 loc) • 9.39 kB
JavaScript
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)``