Skip to content

Instantly share code, notes, and snippets.

@arempe93
Created January 28, 2020 19:55
Show Gist options
  • Save arempe93/6d4b906fa17de4d1db75d4ca87507b66 to your computer and use it in GitHub Desktop.
Save arempe93/6d4b906fa17de4d1db75d4ca87507b66 to your computer and use it in GitHub Desktop.
React autocomplete
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 }
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
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
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
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