Created
January 28, 2020 19:55
-
-
Save arempe93/6d4b906fa17de4d1db75d4ca87507b66 to your computer and use it in GitHub Desktop.
React autocomplete
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useCallback, useEffect, useMemo, useState } from 'react' | |
import styled from 'styled-components' | |
import useDebouncedValue from '@/hooks/useDebouncedValue' | |
import Flex from '@/components/Flex' | |
import Icon from '@/components/Icon' | |
import Input from '@/components/Input' | |
import Text from '@/components/Text' | |
import Suggest from './Suggestion' | |
import Suggestions from './Suggestions' | |
import debug from '@/util/debug' | |
import { KeyCode } from '@/util/enums' | |
const Wrapper = styled.div` | |
width: 100%; | |
position: relative; | |
` | |
const Item = styled(Flex)` | |
height: 2.625rem; | |
padding: 0 1.25rem 0 0.75rem; | |
background-color: ${p => p.disabled ? p.theme.grey100 : 'white'}; | |
border-radius: 0.25rem; | |
border: 1px solid ${p => p.theme.grey200}; | |
cursor: ${p => p.disabled ? 'not-allowed' : 'pointer'}; | |
&:hover { | |
background-color: ${p => p.theme.grey100}; | |
} | |
` | |
export interface Suggestion<T> { | |
active: boolean | |
index: number | |
item: T | |
onSelect: () => void | |
} | |
export type Selection<T> = T | null | |
export interface Props<T> { | |
children?: (suggestions: Array<Suggestion<T>>) => React.ReactElement[] | |
debounce?: number | |
disabled?: boolean | |
getItems: (query: string) => Promise<T[]> | |
initialQuery?: string | |
itemToString: (item: T) => string | |
loadingPrompt?: string | |
noResults?: string | |
placeholder?: string | |
renderItem?: (item: T, onCancel: () => void) => React.ReactElement | |
required?: boolean | |
value: Selection<T> | |
onBlur?: () => void | |
onChange: (item: Selection<T>) => void | |
onFocus?: () => void | |
} | |
// TODO: autofocus after unselecting an item | |
const Autocomplete = <T extends any>({ | |
children, debounce = 250, disabled = false, getItems, initialQuery = '', | |
itemToString, loadingPrompt = 'Loading...', noResults = 'No results', renderItem, | |
required = false, value, onBlur, onChange, onFocus, ...rest | |
}: Props<T>) => { | |
const [query, setQuery] = useState(initialQuery) | |
const [activeIndex, setActiveIndex] = useState(0) | |
const [suggestions, setSuggestions] = useState<Array<Suggestion<T>>>([]) | |
const [isFocused, setIsFocused] = useState(false) | |
const [isLoading, setIsLoading] = useState(false) | |
const debouncedQuery = useDebouncedValue(query, debounce) | |
useEffect(() => { | |
if (disabled) return | |
setIsLoading(true) | |
setActiveIndex(0) | |
debug.log('[Autocomplete] getItems') | |
getItems(debouncedQuery) | |
.then((items) => { | |
setSuggestions(items.map((el, index): Suggestion<T> => ({ | |
active: index === 0, index, item: el, onSelect: () => handleSelect(el) | |
}))) | |
setIsLoading(false) | |
}) | |
.catch((error) => { | |
debug.error('[Autocomplete] getItems =>', error) | |
}) | |
}, [disabled, debouncedQuery]) | |
useMemo(() => { | |
setSuggestions(suggestions => suggestions.map((suggestion, index) => ({ | |
...suggestion, active: index === activeIndex | |
}))) | |
}, [activeIndex]) | |
const handleBlur = useCallback(() => { | |
setActiveIndex(0) | |
setIsFocused(false) | |
if (onBlur) onBlur() | |
}, [onBlur]) | |
const handleFocus = useCallback(() => { | |
setIsFocused(true) | |
if (onFocus) onFocus() | |
}, [onFocus]) | |
const handleChange = useCallback((e: React.SyntheticEvent<HTMLInputElement>) => { | |
setQuery(e.currentTarget.value) | |
if (required) { | |
e.currentTarget.setCustomValidity('Please select an option') | |
} | |
}, [required]) | |
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => { | |
if (e.keyCode === KeyCode.DOWN) { | |
e.preventDefault() | |
setActiveIndex(index => Math.min(index + 1, suggestions.length - 1)) | |
} else if (e.keyCode === KeyCode.UP) { | |
e.preventDefault() | |
setActiveIndex(index => Math.max(index - 1, 0)) | |
} else if (e.keyCode === KeyCode.ENTER) { | |
e.preventDefault() | |
handleSelect(suggestions[activeIndex].item) | |
} | |
}, [suggestions]) | |
const handleSelect = useCallback((item: Selection<T>) => { | |
if (disabled) return | |
setActiveIndex(0) | |
onChange(item) | |
}, [disabled, onChange]) | |
if (value) { | |
if (renderItem) { | |
return renderItem(value, () => handleSelect(null)) | |
} | |
return ( | |
<Item | |
disabled={disabled} | |
justify='space-between' | |
onClick={() => handleSelect(null)} | |
> | |
<Text color='grey800' size={0.875}> | |
{itemToString(value)} | |
</Text> | |
<Text size={0.75}> | |
<Icon name='times' /> | |
</Text> | |
</Item> | |
) | |
} | |
return ( | |
<Wrapper> | |
<Input | |
{...rest} | |
disabled={disabled} | |
required={required} | |
value={query} | |
onBlur={handleBlur} | |
onChange={handleChange} | |
onFocus={handleFocus} | |
onKeyDown={handleKeyDown} | |
/> | |
<Suggestions | |
isFocused={isFocused} | |
isLoading={isLoading} | |
loadingPrompt={loadingPrompt} | |
noResults={noResults} | |
> | |
{children | |
? children(suggestions) | |
: suggestions.map((suggestion, index) => ( | |
<Suggest | |
key={index} | |
active={suggestion.active} | |
onClick={suggestion.onSelect} | |
> | |
{itemToString(suggestion.item)} | |
</Suggest> | |
)) | |
} | |
</Suggestions> | |
</Wrapper> | |
) | |
} | |
export default Autocomplete | |
// NOTE: because we have a type named Suggestion | |
export { Suggest as Suggestion } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useField } from 'hooked-form' | |
import React from 'react' | |
import Autocomplete, { Props as AutocompleteProps } from '@/widgets/Autocomplete' | |
type Props<T> = | |
Omit<AutocompleteProps<T>, 'value' | 'onBlur' | 'onChange' | 'onFocus'> & | |
{ | |
fieldId: string | |
} | |
const AutocompleteField = <T extends any>({ fieldId, ...rest }: Props<T>) => { | |
const [{ onChange, onBlur, onFocus }, { value }] = useField(fieldId) | |
return ( | |
<Autocomplete | |
{...rest} | |
value={value} | |
onBlur={onBlur} | |
onChange={onChange} | |
onFocus={onFocus} | |
/> | |
) | |
} | |
export default AutocompleteField |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import styled, { css } from 'styled-components' | |
import Flex from '@/components/Flex' | |
const activeStyles = css` | |
background-color: ${p => p.theme.primary100}; | |
&:hover { | |
background-color: ${p => p.theme.primary200}; | |
} | |
` | |
const Suggestion = styled(Flex)` | |
padding: 0.5rem 0.75rem; | |
cursor: pointer; | |
font-size: 0.875rem; | |
&:hover { | |
background-color: ${p => p.theme.grey100}; | |
} | |
${p => p.active && activeStyles}; | |
` | |
export default Suggestion |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useState } from 'react' | |
import styled from 'styled-components' | |
import Flex from '@/components/Flex' | |
import Icon from '@/components/Icon' | |
import Text from '@/components/Text' | |
const Wrapper = styled.div` | |
display: ${p => p.isHidden ? 'none' : 'flex'}; | |
flex-direction: column; | |
padding: 0.375rem 0; | |
left: 0; | |
position: absolute; | |
right: 0; | |
top: calc(100% + 0.25rem); | |
background-color: white; | |
border: 1px solid ${p => p.theme.grey400}; | |
border-radius: 0.375rem; | |
box-shadow: ${p => p.theme.shadows.raisedMore}; | |
z-index: 1000; | |
` | |
const Loading = styled(Flex)` | |
padding: 0.75rem 0; | |
` | |
interface Props { | |
children: React.ReactElement[] | |
isFocused: boolean | |
isLoading: boolean | |
loadingPrompt: string | |
noResults: string | |
} | |
const Suggestions = ({ | |
children, isFocused, isLoading, loadingPrompt, noResults | |
}: Props) => { | |
const [isMouseDown, setIfMouseDown] = useState(false) | |
const handleMouseDown = () => setIfMouseDown(true) | |
const handleMouseUp = () => setIfMouseDown(false) | |
return ( | |
<Wrapper | |
isHidden={!(isFocused || isMouseDown)} | |
onMouseDown={handleMouseDown} | |
onMouseUp={handleMouseUp} | |
onTouchEnd={handleMouseUp} | |
onTouchStart={handleMouseDown} | |
> | |
<> | |
{isLoading && | |
<Loading justify='center'> | |
<Text color='grey800' size={0.875}> | |
<Icon spin name='circle-notch' /> | |
</Text> | |
<Text size={0.875}> | |
{loadingPrompt} | |
</Text> | |
</Loading> | |
} | |
{React.Children.count(children) === 0 && | |
<Loading justify='center'> | |
<Text size={0.875}> | |
<em>{noResults}</em> | |
</Text> | |
</Loading> | |
} | |
{children} | |
</> | |
</Wrapper> | |
) | |
} | |
export default Suggestions |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useState } from 'react' | |
const useDebouncedValue = <T extends any>(value: T, delay: number = 500): T => { | |
const [state, setState] = useState(value) | |
useEffect(() => { | |
const handler = setTimeout(() => setState(value), delay) | |
return () => clearTimeout(handler) | |
}, [value]) | |
return state | |
} | |
export default useDebouncedValue |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment