React 앱에 단축키 적용하기

Juno·2022년 8월 7일
2

✨ 들어가기

이번 포스팅에서는 React로 구성된 app에서 단축키를 적용한 경험을 공유하고자 합니다.
비교적 간단한 내용이지만, 단순히 단축키로 특정 이벤트를 발생시키는 방법에 대한 글들이 많고 실제 적용하면서 고려해야 할 점들을 같이 작성하여 작업을 진행하면서 고려했던 점, 사용한 라이브러리 등에 대한 글은 찾기 어려웠기 때문에 다른분들께도 도움이 되었으면 하고 작성해 보았습니다 💪

📑 요구사항

제가 개발하고 있는 파트너센터에서는 셀러들이 상품을 등록, 수정 하는 등 반복적인 요청 작업이 많기 때문에 여러번의 확인, 알림 모달을 거쳐가야만 원하는 작업을 진행할 수 있습니다. 그에 따라 마우스로 모달의 버튼들을 클릭해야 하고 피로도를 많이 높였기 때문에 몇가지의 기능들에 대해서 단축키를 적용해달라는 VOC가 많이 접수되었습니다.

이런 필요성에 따라 모달에 대한 단축키, 사이드바에 대한 단축키 를 적용하는 것을 요구사항으로 전달받았습니다.

🤔 생각하기

우선, 전달받은 요구사항은 두 가지 기능에 대한 단축키를 추가해달라는 것 이었지만 중요한 기능들에 대해 우선적으로 적용하는 것이고 앞으로도 추가될 다른 단축키를 고려해야 했습니다. 확인 과 같은 비슷한 기능이면 enter로 통일할 수 있지만 명확히 다른 기능들에 대해서는 다 다르게 부여해야 하고 앱이 지원하는 브라우저의 기본 단축키는 피하여 지정해야 했습니다. (ex 크롬 기본 단축키)

이러한 측면에서 각 기능에 대한 단축키는 충분히 사용성이 고려되어 정해져야 했고, 이를 위해 PO와 PD분들의 도움을 받아 각각의 단축키를 정하고 이를 정리해주셨습니다. (이제 그대로 구현만 하면 됩니다..!!)

💡 global vs local

우선, 사이드바에 대한 단축키는 고려해야할 점이 많지는 않았어요. 파트너센터는 사이드바 형태로 메뉴가 제공되는데, 필요시 넓은 화면을 제공하기 위해 이를 열고 닫는 기능이 있었습니다. 이를 단축키로 제공하여야 했는데, 어떤 화면이든 항상 사이드바는 존재하기 때문에 전역으로 단순 적용할 수 있었습니다.

모달에 대한 단축키는 조금은 사정이 달랐던게, 모달이 열려있을때만 지역적으로 적용되어야 했습니다.
1. 전역적으로 적용되어 있는 같은 확인 버튼에 대한 단축키가 생길 경우
2. 모달이 여러개가 겹처서 떠야하는 경우
에 대한 고려가 필요했기 때문에 모달에 대한 단축키는 지역적으로 적용될 필요가 있었죠.

💁🏻‍♂️ 원리는 간단해요!

사용자의 키보드 입력 이벤트를 받아서 해당 이벤트가 발생했을때 콜백함수가 실행되도록 구현해주면 됩니다.

import { useCallback, useEffect } from 'react';

export default function App() {
  // handle what happens on key press
  const handleKeyPress = useCallback((event) => {
    console.log(`Key pressed: ${event.key}`);
  }, []);

  useEffect(() => {
    // attach the event listener
    document.addEventListener('keydown', handleKeyPress);

    // remove the event listener
    return () => {
      document.removeEventListener('keydown', handleKeyPress);
    };
  }, [handleKeyPress]);

  return (
    <div>
      <h1>Hello world</h1>
    </div>
  );
}

keydown 이벤트가 발생했을 때 handleKeyPress콜백함수를 실행시켜주는 형태가 되겠네요.

✍🏻 keydown vs keyup

  • keydown: 키가 눌렸을 때 발생하는 이벤트
  • keyup: 키가 눌렀다가 떼어졌을 때 발생하는 이벤트

다음과 같은 원리로 직접 훅 형태로 필요한 인터페이스들을 제공할 수도 있지만, 사용자가 많은 잘 구현된 라이브러리가 있어서 위에 정리한 원리로 keydown 이벤트를 통해 단축키를 구현한 라이브러리를 사용하고자 했습니다.

👀 react-hotkeys vs react-hotkeys-hook

단축키를 제공하는 라이브러리로 가장 많이 사용되는 것이 hotkeys라는 라이브러리 였어요. 이를 사용해도 좋지만 React 버전으로 작성된게 아니었고, 가이드에서도 React를 위한 hotkeys 라이브러리를 래핑한 두 가지 라이브러리를 추천해주고 있었습니다. 소제목에서 언급한 react-hotkeysreact-hotkeys-hook 입니다.


다운로드 수는 react-hotkeys가 더 많았지만 사용하기 편리하게 hook 형태로 제공해주고, 예시나 가이드 문서 페이지가 따로 작성되어 있는 점과 비교적 최근에도 계속 업데이트가 이뤄지고 있는 점 마지막으로 무엇보다 같은 라이브러리를 래핑한 것이기 때문에 굳이 다운로드 수를 크게 상관하지 않고 편한 쪽으로 사용하고자 react-hotkeys-hook을 사용하기로 결정하였습니다.

적용하기

enum ModalHotkeys {
	COMFIRM: 'enter',
  	CLOSE: 'esc'
}

interface ModalProps {
  	// ...
  	onConfirm?: ()=> void;
	onClose?: () => void;
}

export default function Modal({ onClose, onConfirm ...props }: ModalProps) {
	useHotkeys(`${ModalHotkeys.CONFIRM}, ${ModalHotkeys.CLOSE}`, (_, handler) => {
    	switch (handler.key) {
	      case ModalHotkeys.CONFIRM:
    	    onConfirm?.();
        	break;
	      case ModalHotkeys.CLOSE:
    	    onClose?.();
        	break;
    }
  });
  
  return <StyledModal />
}

다음과 같은 형태로 공용으로 사용되는 모달 컴포넌트에 대해 간단히 단축키를 적용할 수 있었습니다. 하지만, useHotKeys 와 같은 경우 hotkeys를 useEffect 훅으로 감싼 형태로 이대로 사용할 경우, 전역적으로 사용됩니다. 앞서 언급한 sideEffect를 피하기 위해 지역적으로 사용이 필요합니다.

scoping과 enabled

두 가지 방법으로 지역적으로 단축키를 적용할 수 있습니다. 먼저 scoping 인데요, 컴포넌트가 포커싱되었을 때 핫키가 적용되는 scoping 기능을 제공합니다.

다만, focus가 적용되는 element와 그렇지 않은 element가 나뉘어 있기 때문에 포커싱이 되지 않는 엘리먼트에 대해서는 tabIndex 라는 global attributes를 조정하여 키보드 네비게이팅은 되지 않지만, 포커싱이 가능하도록 조정이 필요합니다.

💡 참고하기: tabIndex = -1
음의 정숫값(보통 tabindex="-1")은 연속 키보드 탐색으로 접근할 수는 없으나 JavaScript나 시각적(마우스 클릭)으로는 포커스 가능함을 뜻합니다. 보통 JavaScript를 사용한 위젯의 접근성 확보를 위해 사용합니다.
(https://developer.mozilla.org/ko/docs/Web/HTML/Global_attributes/tabindex)

공식문서에서 안내하는 예시처럼 모달의 컴포넌트 자체는 keyboard-navigable 할 필요가 없으므로 tabIndex = -1로 부여하여 포커싱이 가능하도록 합니다. 그리고 useHotKeys는 Ref를 반환하는데, 이를 ref 속성으로 지정해주면 됩니다. (동작하는 자세한 예시는 공식문서의 예시를 참고해주세요)

export default function Modal({ onClose, onConfirm ...props }: ModalProps) {
	const ref = useHotkeys(`${ModalHotkeys.CONFIRM}, ${ModalHotkeys.CLOSE}`, (_, handler) => {
    	switch (handler.key) {
	      case ModalHotkeys.CONFIRM:
    	    onConfirm?.();
        	break;
	      case ModalHotkeys.CLOSE:
    	    onClose?.();
        	break;
    }
  });
  
  return <StyledModal ref={ref} tabIndex={-1} />
}

이 외에도 useHotKeys 훅의 Option 인자 중 enabled 속성을 활용할 수도 있습니다. 모달이 열리고 닫히는 여부를 판단하는 상태값을 보통 컴포넌트 내부에 가지고 있기 때문에 이를 바탕으로 핫키를 적용할지 여부를 결정하도록 구현해볼수도 있습니다.

export default function Modal({ onClose, onConfirm ...props }: ModalProps) {
	const [isOpen, setIsOpen] = useState<boolean>(false);
  
	useHotkeys(`${ModalHotkeys.CONFIRM}, ${ModalHotkeys.CLOSE}`, (_, handler) => {
    	switch (handler.key) {
	      case ModalHotkeys.CONFIRM:
    	    onConfirm?.();
        	break;
	      case ModalHotkeys.CLOSE:
    	    onClose?.();
        	break;
    	}
	},
	{
    	enabled: isOpen
    }, [isOpen]);
  
  return <StyledModal tabIndex={-1} />
}

👏 마치며

이번 포스팅에서는 단축키를 적용하는 방법을 정리한 글이라서 따로 추가하진 않았는데요, 모달과 같은 경우는 focus-trap을 이용해서 포커싱을 모달안에 가두고, 키보드 탭키를 통해(위에 언급한 keyboard navigate) 모달 내부의 버튼들로 포커싱을 옮기면서 동작하도록 구현해 볼 수도 있습니다.

확인버튼이 있는 경우는 초기 포커싱을 확인버튼으로 가져가고 그게 아니라면 닫기버튼으로 가져가서(initial-focus의 기본값은 focusable한 element중 첫번째 요소입니다.) 따로 단축키 활용 없이도 구현해볼 수 있겠습니다! 저도 실제로 구현할 땐 이미 Modal 컴포넌트에 focus-trap을 활용중이었기 때문에 닫기버튼(esc)에 대해서만 단축키를 적용하고 확인버튼은 포커싱을 통해 제어하는 방식으로 구현하였습니다. 방법은 이렇게 여러가지일 것 같아요!

📝 참고자료

profile
사실은 내가 보려고 기록한 것 😆

0개의 댓글