Skip to content

Instantly share code, notes, and snippets.

@dmtpln
Last active February 14, 2021 12:00
Show Gist options
  • Save dmtpln/39fa0a9a2a217e79b8995034a1a1dd0b to your computer and use it in GitHub Desktop.
Save dmtpln/39fa0a9a2a217e79b8995034a1a1dd0b to your computer and use it in GitHub Desktop.
Chakra-ui async autocomplete
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Input, InputGroup, InputProps, InputRightElement, Spinner } from '@chakra-ui/react';
import styled from '@emotion/styled';
export const Container = styled.div`
position: relative;
`;
interface SuggestionItemProps {
highlighted?: boolean;
}
export const SuggestionItem = styled.div<SuggestionItemProps>`
cursor: pointer;
padding: 12px 10px;
background-color: ${(props) => (props.highlighted ? '#eee' : 'transparent')};
`;
export interface Suggestion {
[key: string]: any;
}
export interface AutocompleteInputProps extends Omit<InputProps, 'onChange' | 'onSelect' | 'value'> {
suggestionLabelField?: string;
suggestionValueField?: string;
suggestionsLimit?: number;
value?: string;
loading?: boolean;
suggestions: Suggestion[];
onChange?: (value: string) => void;
onSelect?: (suggestion: Suggestion) => void;
}
export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
suggestions: inSuggestions = [],
value: inValue = '',
suggestionValueField = 'value',
suggestionLabelField = 'label',
suggestionsLimit = 10,
loading,
onChange,
onSelect,
...props
}) => {
const containerRef = useRef<HTMLDivElement>();
const [value, setValue] = useState(inValue);
const [visibleValue, setVisibleValue] = useState(value);
const [isFocused, setIsFocused] = useState(false);
const [suggestions, setSuggestions] = useState(inSuggestions);
const [isSuggestionsHidden, setIsSuggestionsHidden] = useState(false);
const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState<number>(null);
const handleChange = useCallback(
(e) => {
setHighlightedSuggestionIndex(null);
setValue(e.target.value);
if (typeof onChange === 'function') {
onChange(e.target.value);
}
},
[onChange]
);
const handleFocus = useCallback(() => {
setIsSuggestionsHidden(false);
setIsFocused(true);
if (typeof onChange === 'function') {
onChange(value);
}
}, [value, onChange]);
const handleClickInput = useCallback(() => setIsSuggestionsHidden(false), []);
const handleClickDocument = useCallback(
(e) => {
if (!containerRef.current?.contains(e.target)) {
setIsFocused(false);
}
},
[containerRef]
);
const handleClickSuggestion = useCallback(
(index: number, e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
const suggestion = suggestions[index];
setValue(suggestion[suggestionLabelField]);
setIsFocused(false);
setSuggestions([]);
if (typeof onSelect === 'function') {
onSelect(suggestion);
}
},
[suggestions, suggestionLabelField, onSelect]
);
const handlePressUpDownArrow = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
if (suggestions.length === 0) {
return false;
}
setHighlightedSuggestionIndex((oldIndex) => {
if (
(oldIndex === 0 && e.key === 'ArrowUp') ||
(suggestions.length - 1 === oldIndex && e.key === 'ArrowDown')
) {
setVisibleValue(value);
return null;
}
let newIndex = oldIndex + (e.key === 'ArrowDown' ? 1 : -1);
if (oldIndex === null && e.key === 'ArrowDown') {
newIndex = 0;
}
if (oldIndex === null && e.key === 'ArrowUp') {
newIndex = suggestions.length - 1;
}
setVisibleValue(suggestions[newIndex][suggestionLabelField]);
return newIndex;
});
},
[suggestions, value]
);
const handlePressEnter = useCallback(
(e) => {
e.preventDefault();
if (highlightedSuggestionIndex !== null) {
const suggestion = suggestions[highlightedSuggestionIndex];
setValue(suggestion[suggestionLabelField]);
setSuggestions([]);
if (typeof onSelect === 'function') {
onSelect(suggestion);
}
}
},
[highlightedSuggestionIndex, suggestionLabelField, suggestions, onSelect]
);
const handleKeyDown = useCallback(
(e) => {
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp':
handlePressUpDownArrow(e);
break;
case 'Enter':
handlePressEnter(e);
break;
case 'Escape':
setIsSuggestionsHidden(true);
break;
case 'Tab':
setIsSuggestionsHidden(true);
break;
}
},
[handlePressUpDownArrow, handlePressEnter]
);
useEffect(() => setValue(inValue), [inValue]);
useEffect(() => setVisibleValue(value), [value]);
useEffect(() => {
setIsSuggestionsHidden(false);
setSuggestions(inSuggestions);
}, [inSuggestions]);
useEffect(() => {
document.addEventListener('click', handleClickDocument);
return () => {
document.removeEventListener('click', handleClickDocument);
};
}, []);
return (
<Container ref={containerRef}>
<InputGroup>
<Input
value={visibleValue}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onClick={handleClickInput}
{...props}
/>
{loading && (
<InputRightElement>
<Spinner size='sm' thickness='1px' />
</InputRightElement>
)}
</InputGroup>
{suggestions.length > 0 && !loading && isFocused && !isSuggestionsHidden && (
<Box
bg='white'
pos='absolute'
top='48px'
zIndex='1000'
w='100%'
borderWidth='1px'
borderRadius='md'
overflow='hidden'
shadow='md'
>
{suggestions.slice(0, suggestionsLimit).map((suggestion, index) => (
<SuggestionItem
key={suggestion[suggestionValueField]}
highlighted={highlightedSuggestionIndex === index}
onClick={(e) => handleClickSuggestion(index, e)}
onMouseEnter={() => setHighlightedSuggestionIndex(index)}
>
{suggestion[suggestionLabelField]}
</SuggestionItem>
))}
</Box>
)}
</Container>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment