프론트엔드/리액트 | 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
반응형