프론트엔드/리액트 | React

[React / Typescript] Create AutoComplete from scratch 리액트로 autocomplete 구현하기

개발자R 2023. 1. 31. 14:01
반응형

mui 라이브러리를 사용하지 않고 처음부터 끝까지 AutoComplete 를 구현했다.

생각보다 신경쓸게 많았다.

제일 빡셌던건 방향키로 목록을 탐색할 때 스크롤을 자연스럽게 움직이게 하는 것이었다.

처음엔 자바스크립트 기본 함수인 scrollIntoView() 를 사용해볼까 했는데 내가 생각하는거랑 전혀 다르게 움직여서 그것까지 직접 구현했다.

(그래서 코드가 좀 더러움;)

 

 

AutoComplete.tsx

import { useState, useRef, useEffect } from "react";
import { getLocalStorageValue } from "./utils/localstorage.util";
import styles from "./AutoComplete.module.css";
import { Button } from "..."; //버튼은 기존에 쓰던게 있었음
interface AutoCompleteProps {
placeholder: string;
storageKey: string;
handleSearch?: Function;
onSelected?: any;
onChange?: any;
inputRef?: any;
}
// ipnut에서 엔터가 자동완성선택 / 검색 이 있기 때문에 구분을 하기 위한 변수
// 키보드 방향키로 움직였을 때에는 false 로 했다가 엔터를 누르면 true 로 변경
let enterKeyFor: "search" | "autoComplete" = "search";
const AutoComplete = (props: AutoCompleteProps) => {
let storagedSuggestions: string[] = getLocalStorageValue(props.storageKey) || [];
const [suggestions, setSuggestions] = useState<string[]>(storagedSuggestions);
const [showSuggstions, setShowSuggstions] = useState(false);
const [selectedVal, setSelectedVal] = useState("");
const [activeSuggestionIdx, setActiveSuggestionIdx] = useState(-1);
const [showCloseButton, setShowCloseButton] = useState(false);
const suggestionRef = useRef<HTMLDivElement>(null);
const handleKeyUp = (e: any) => {
setSuggestions(storagedSuggestions.filter(i => i.startsWith(e.target.value.toUpperCase())));
};
const handleChange = (e: any) => {
const input = e.target.value;
setActiveSuggestionIdx(-1);
setShowSuggstions(true);
setSelectedVal(input);
props.onChange(input);
};
/**
* 로컬스토리지 값 가져오기
*/
const refreshStoragedSuggestions = () => {
storagedSuggestions = getLocalStorageValue(props.storageKey) || [];
setSuggestions(storagedSuggestions.filter(i => i.startsWith(selectedVal.toUpperCase())));
};
/**
* x 버튼 설정
*/
useEffect(() => {
if (!selectedVal) {
setShowCloseButton(false);
} else {
setShowCloseButton(true);
}
}, [selectedVal]);
/**
* 스크롤 관련
*/
useEffect(() => {
//선택중인 suggestion
const currentChildren = suggestionRef.current?.children[activeSuggestionIdx];
const currentChildrenTop = currentChildren?.getBoundingClientRect().top || 0;
const currentChildrenBottom = currentChildren?.getBoundingClientRect().bottom || 0;
// suggestions 컨테이너
const containerTop = suggestionRef.current?.getBoundingClientRect().top || 0;
const containerBottom = suggestionRef.current?.getBoundingClientRect().bottom || 0;
const scrollTop = suggestionRef.current?.scrollTop || 0;
if (currentChildrenTop < containerTop) {
suggestionRef.current?.scroll({
top: scrollTop - (containerTop - currentChildrenTop),
});
} else if (currentChildrenBottom > containerBottom) {
suggestionRef.current?.scroll({
top: scrollTop + (currentChildrenBottom - containerBottom),
});
}
}, [activeSuggestionIdx]);
/**
* 마우스 클릭 / 엔터키로으로 값 셋팅
*/
const setSuggestion = (value: string) => {
props.onSelected(value);
setSelectedVal(value);
setShowSuggstions(false);
setActiveSuggestionIdx(-1);
};
/**
* 방향키, 엔터키 관련
*/
const onKeyDown = (e: any) => {
//엔터키
if (e.keyCode === 13) {
// 검색을 위한 엔터인 경우
if (enterKeyFor === "search") {
props.handleSearch!();
addLocalStorageListValue(
props.storageKey,
selectedVal?.trim().toUpperCase(),
true,
true,
);
setShowSuggstions(false);
}
// 자동완성을 위한 엔터인 경우
else if (enterKeyFor === "autoComplete") {
setSuggestion(suggestions[activeSuggestionIdx]);
enterKeyFor = "search";
}
}
//방향키 (위)
else if (e.keyCode === 38) {
enterKeyFor = "autoComplete";
if (activeSuggestionIdx === 0) {
setActiveSuggestionIdx(suggestions.length - 1);
} else {
setActiveSuggestionIdx(activeSuggestionIdx - 1);
}
}
// 방향키 (아래)
else if (e.keyCode === 40) {
enterKeyFor = "autoComplete";
if (activeSuggestionIdx === suggestions.length - 1) {
setActiveSuggestionIdx(0);
} else {
setActiveSuggestionIdx(activeSuggestionIdx + 1);
}
}
// 기타 다른 키
else {
enterKeyFor = "search";
}
};
/**
* 마우스 동작
*/
const handleMouseOver = (idx: number) => {
setActiveSuggestionIdx(idx);
};
return (
<div className={styles.sugesstionAuto}>
<div className={styles.formControlAuto}>
<input
placeholder={props.placeholder}
type="search"
value={selectedVal || ''}
ref={props.inputRef}
onChange={handleChange}
onKeyUp={handleKeyUp}
onKeyDown={onKeyDown}
onFocus={() => {
refreshStoragedSuggestions();
setShowSuggstions(true);
}}
onBlur={() => {
setTimeout(() => {
setShowSuggstions(false);
}, 200);
}}
/>
{showCloseButton && (
<Button
onClick={() => {
setSuggestion("");
}}
display="icon"
iconName="close"
variant="clear"
color="grey"
size={20}
/>
)}
</div>
{showSuggstions && suggestions.length > 0 && (
<div
className={styles.suggestions}
style={{ display: showSuggstions ? "block" : "none" }}
ref={suggestionRef}
>
{suggestions.map((item, idx) => (
<div
className={idx === activeSuggestionIdx ? styles.active : ""}
key={"" + item + idx}
onMouseOver={() => handleMouseOver(idx)}
onKeyDown={onKeyDown}
onClick={() => setSuggestion(item)}
>
{item}
</div>
))}
</div>
)}
</div>
);
};
export default AutoComplete;

 

AutoComplete.module.css

.sugesstionAuto {
display: block;
position: relative;
width: 184px;
padding: 1px 2px 1px 8px;
}
.formControlAuto {
display: flex;
justify-content: center;
align-items: center;
justify-content: space-between;
padding-left: 5px;
}
.formControlAuto input {
border: 0;
width: 100%;
/* padding: 15px 20px; */
outline: none;
background-color: transparent !important
}
.formControlAuto label {
font-size: 10px;
text-transform: uppercase;
color: #949494;
padding: 10px 0px 10px 20px;
}
.suggestions {
position: absolute;
top: 31px;
left: -1px;
z-index: 999;
cursor: pointer;
width: 222px;
max-height: 200px;
overflow: auto;
border: 1px solid #dde0e2;
background-color: white;
}
.suggestions > div {
padding: 10px;
}
.active{
background-color: #dbdbdb !important;
color: #077110 !important;
}
/* .suggestions > div,
.formControlAuto input {
color: #ffffff;
} */

 

localstorage.util.ts

const getLocalStorageValue = (key: string) => {
const value = localStorage.getItem(key);
if (value && isJSONObject(value)) {
return JSON.parse(value);
} else {
return value;
}
};
/**
* 값 덮어씌우기
*/
const setLocalStorage = (key: string, value: string) => {
localStorage.setItem(key, value);
};
/**
* list 형태에 값 추가하기.
*/
const addLocalStorageListValue = (
key: string,
value: any,
sorted: boolean = false,
noDuplication: boolean = false,
) => {
const oldValueStr = localStorage.getItem(key);
//값이 없으면 리스트 생성 후 저장
if (!oldValueStr) {
const newValueObj = [value];
localStorage.setItem(key, JSON.stringify(newValueObj));
return newValueObj;
} else if (!isJSONObject(oldValueStr)) return; //JSON이 아니면 아무동작 하지 않음
else {
const oldValueObj = JSON.parse(oldValueStr);
if (Array.isArray(oldValueObj)) {
let newValueObj: string[] = [];
//중복 제거
newValueObj =
noDuplication && oldValueObj.indexOf(value) > -1
? [...oldValueObj]
: [...oldValueObj, value];
//sorting
if (sorted) {
newValueObj.sort();
}
localStorage.setItem(key, JSON.stringify(newValueObj));
return newValueObj;
}
}
};
const isJSONObject = (jsonString: string) => {
try {
var o = JSON.parse(jsonString);
if (o && typeof o === "object") {
return true;
}
} catch (e) {}
return false;
};
export { getLocalStorageValue, setLocalStorage, addLocalStorageListValue };

 

 

const STORAGE_KEY = "quickSearch";
const SearchField = () => {
const searchValueRef = useRef<HTMLInputElement>(null);
const [searchValue, setSearchValue] = useState("");
const getSelectedVal = (value: string) => {
setSearchValue(value);
};
const getChanges = (value: string) => {
setSearchValue(value);
};
const search = () => {
//검색 구현
}
return (
<Autocomplete
placeholder="type any word..."
onSelected={getSelectedVal}
onChange={getChanges}
handleSearch={search}
storageKey={STORAGE_KEY}
inputRef={searchValueRef}
/>
<Button onClick={() => search()} />
)
}
export default SearchField
반응형