Keyboard Navigation(react)

heyj·2022년 3월 18일
7

wanted_PreOnboarding

목록 보기
3/7
post-thumbnail

1. Auto Complete 만들기

auto completing은 검색기능을 만들 때 필수적으로 구현해야 하는 기능입니다.
알파벳을 하나 이상 input에 입력하면 그 입력값으로 시작하는 추천 검색어가 랜더링돼 user에게 보여지는 것이 핵심입니다.

  1. 입력 알파벳이 하나 이상이면 suggestion list 보이기

  2. suggestion을 클릭하면 그 값을 input value로 세팅하기

  3. input에 알파벳을 입력 후 suggestion을 클릭하지 않은 상태에서, input과 suggestion 외부에서 클릭이벤트가 발생했을 때 - suggestion list가 닫히도록 하기

  4. Keyboard Navigation이 가능하도록 하기
    4-1. 키보드 방향키로 검색어 선택
    4-2. 'escape'과 'enter'가 들어오면 list가 닫히거나 추천검색어 값이 input에 셋팅되도록 하기
    4-3. 키보드와 마우스가 번갈아 사용되어도 선택이 자연스럽게 연결되도록 하기

세부적으로 4개 기능을 정리해 코드를 작성하기 시작했습니다.

2. Keyboard Navigation

인풋에 검색어가 입력된 이후 list 드롭박스가 열리면 이후 키보드 방향키로 검색어에 접근이 가능해야 했습니다.

상태관리가 필요했던 것은 3가지 정도로 정리됐습니다.
1. 드롭박스가 열렸는지
2. 마우스가 리스트 목록에서 이동중인지
3. 리스트 인덱스 값

const [isShowing, setIsShowing] = useState(false);
const [isMovingMouse, setIsMovingMouse] = useState(false);
const [cursor, setCursor] = useState(-1);

2-1. 이벤트 적용하기

검색어 입력이 들어오면 드롭박스가 열렸는지를 체크하고, 만약 열려있다면 키보드 이벤트가 발생하도록 세팅했습니다.

키보드 방향키가 입력되면 list index와 연결되어 있는 cursor 값을 +1 혹은 -1을 해주면서 list요소에 접근할 수 있도록 했습니다.

const keyboardNavigation = (e) => {
    if (e.key === 'ArrowDown') {
      isShowing &&
        setCursor((prev) => (prev < data.data.length - 1 ? prev + 1 : prev));
    }
    if (e.key === 'ArrowUp') {
      isShowing && setCursor((prev) => (prev > 0 ? prev - 1 : 0));
    }
    if (e.key === 'Escape') {
      setCursor(-1);
      setIsShowing(false);
    }
    if (e.key === 'Enter' && cursor > 0) {
      setSearchText(data.data[cursor].name);
      setCursor(-1);
      setIsShowing(false);
    }
  }


useEffect(() => {
  window.addEventListener('keydown', keyboardNavigation);
}, []);

li tag에는 selected 속성을 두고 cursor값과 li 요소의 index값이 같을 경우 마우스가 호버될 때와 동일한 CSS 효과를 줬습니다.

{data.map((item, index) => (
  <Result
    key={index}
    selected={cursor === index}
    onKeyUp={handleSelect}
    >
    <Search />
    <ResultText>{item.name}</ResultText>
  </Result>
))}

const Result = styled.li`
  margin-top: 5px;
  width: 100%;
  padding: 13px 24px;
  cursor: pointer;
  &:hover {
    background-color: #f0f0f0;
  }
  background-color: ${(props) => props.selected && '#f0f0f0'};
`;

2.2 useEffect, useCallback

useEffect를 이용해 이벤트가 발생하면 함수를 호출하도록 해뒀는데,
아무리 방향키를 눌러도 값이 변하지 않는 문제가 발생했습니다.

이 때부터 문제 해결방법을 찾는데 2시간이나 소요됐습니다..ㅠㅠ

컴포넌트가 렌더링될 때 keyboardNavigation함수 렌더링과 eventListener의 함수 호출이 셋팅됩니다.

드롭박스의 값이 변경되는 시점은 검색어 입력이 들어오는 시점이므로 컴포넌트 렌더링 이후 입니다. 따라서 이벤트리스너에 등록된 함수는 isShowing 값이 변하기 전에 만들어진 함수이므로 아무리 방향키를 눌러도 cursor값이 변경되지 않았던 것이었습니다. (isShowing은 계속 초기값인 false가 찍히고, cursor값은 영원히 -1만 가지고 있었습니다;;)

문제 해결을 위해 keyboardNavigation함수는 isShowing 등 값이 변경될 때마다 다시 만들어지도록 해야 했습니다. useCallback을 이용해 함수를 캐싱하되, deps의 값들이 변경되면 새로운 함수를 만들도록 했습니다.

useEffect는 컴포넌트가 처음 마운트 될 때에도 호출이 되고, 지정한 값이 바뀔 때에도 호출이 됩니다. deps안에 keyboardNavigation함수를 넣어 바뀐 값이 호출되도록 했습니다.

const keyboardNavigation = useCallback(
  (e) => {
    if (e.key === 'ArrowDown') {
      isShowing &&
        setCursor((prev) => (prev < data.data.length - 1 ? prev + 1 : prev));
    }
    if (e.key === 'ArrowUp') {
      isShowing && setCursor((prev) => (prev > 0 ? prev - 1 : 0));
    }
    if (e.key === 'Escape') {
      setCursor(-1);
      setIsShowing(false);
    }
    if (e.key === 'Enter' && cursor > 0) {
      setSearchText(data.data[cursor].name);
      setCursor(-1);
      setIsShowing(false);
    }
  },
  [data, isShowing, setCursor, setIsShowing, cursor],
);

useEffect(() => {
  window.addEventListener('keydown', keyboardNavigation);
}, [keyboardNavigation]);

...
너무 잘되서 눈물을 흘렸습니다.

2.3 메모리 정리를 위한 cleanup함수 적용

컴포넌트가 삭제될 경우 이벤트 함수 제거 및 메모리 정리를 위해 cleanup함수를 적용했습니다.

Unmount 될 때만 cleanup 함수를 실행시키고 싶다면 deps에 빈 배열을,
특정 값이 업데이트되기 직전에 cleanup 함수를 실행시키고 싶다면 deps에 해당 값을 넣어주면 됩니다.

  useEffect(() => {
    window.addEventListener('keydown', keyboardNavigation);
    return () => {
      window.removeEventListener('keydown', keyboardNavigation);
    };
  }, [keyboardNavigation]);

3. 마우스, 키보드 번갈아 사용하기

마우스가 요소에서 움직이면 keyboardNavigation은 작동하지 않고, 다시 keyEvent가 들어오면 요소에 접근할 수 있도록 상태변수를 세팅했습니다. 마우스가 hover됐던 곳의 index를 기억하도록 하고 keyEvent로 접근한 요소의 index도 기억하도록 해 번갈아 사용할 수 있도록 했습니다.

const keyboardNavigation = useCallback(
  (e) => {
    setIsMovingMouse(false);
    if (e.key === 'ArrowDown') {
	....
    ...
);

const mousedown = (index) => {
  setIsMovingMouse(true);
  setCursor(index);
};

{data.map((item, index) => (
  <Result
    key={index}
    selected={cursor === index}
    onMouseMove={() => mousedown(index)}
    onKeyUp={handleSelect}
    onClick={handleSelect}
    >
    <Search />
    <ResultText>{item.name}</ResultText>
  </Result>
))}

4. 완성

0개의 댓글