Skip to content

드래그 인터랙션의 시각장애인 접근성 개선기

lybell edited this page Aug 26, 2024 · 1 revision

배경상황

저희 프로젝트는 3개의 드래그 기반 인터랙션을 가지고 있어요.주행거리 인터랙션(점을 드래그하여 점과 중앙의 거리를 예측), 고속충전 인터랙션(다이얼을 드래그로 회전하여 충전 시간을 예측), 유니버설 아일랜드 인터랙션(오브젝트 2개를 드래그하여 유니버설 아일랜드 체험, 스마트폰 오브젝트를 받침대 오브젝트에 스냅해서 무선충전 체험)이 그것이에요.

드래그 기반 인터랙션은 사용자가 조작하기 쉬워서 클릭 다음으로 많이 사용되는 인터랙션이지만, 드래그 자체가 2차원 환경에 의존하면서, 시각적으로 눌린 상태와 현재 드래그 중인 좌표에 의존하기 때문에, 시각장애인이 사용하기 어려운 인터랙션이에요. 저희는 장애가 있다는 이유로 인터랙션을 누릴 기회를 박탈시키고 싶지 않았어요. 더 많은 사람들에게 인터랙션이 주는 경험을 제공하고 싶었기 때문에, 시각장애인이 사용하기 쉬운 방식으로 드래그 인터랙션을 개선하기로 했어요.

드래그 인터랙션의 접근성, 어떻게 높일까?

일반적으로, 시각장애인은 커서라는 시각적 정보에 의존해야 조작할 수 있는 마우스 대신, 버튼 기반의 키보드로 웹 브라우저를 조작해요. 그렇기 때문에, 저희의 드래그 인터랙션을 키보드로 조작할 수 있도록 바꿔야 할 필요가 있어요. 드래그 인터랙션의 요소를 뜯어보자면, "드래그를 시작하고 종료하는 트리거"와 "2차원 오브젝트의 위치 변경"로 구분할 수 있어요. 저희는 "드래그를 시작하고 종료하는 트리거"를 스페이스바로, "2차원 오브젝트의 위치 변경"을 방향키를 통한 이동으로 대체할 수 있다고 생각했어요.

저희는 위의 조작을 수행하는, 키보드 조작을 마운트하는 훅을 만들었어요.

function useA11yDrag({
  onKeyGrab,
  onKeyMove,
  onKeyRelease,
  setGrabStatus,
  enabled = true,
}) {
  const target = useRef(null);
  const grabbed = useRef(false);

  useEffect(() => {
    if (target.current === null || !enabled) return;

    function onKeyDown(e) {
      if (document.activeElement !== target.current) return;

      if (grabbed.current) {
        switch (e.code) {
          case "Tab": {
            e.preventDefault();
            break;
          }
          case "Space": {
            grabbed.current = false;
            setGrabStatus("drop");
            e.preventDefault();
            onKeyRelease?.();
            break;
          }
          case "ArrowUp":
          case "ArrowDown":
          case "ArrowLeft":
          case "ArrowRight": {
            const [x, y] = getDir(e.code);
            onKeyMove(x, y);
            setGrabStatus("dragging");
            e.preventDefault();
            break;
          }
        }
      } else if (e.code === "Space") {
        grabbed.current = true;
        setGrabStatus("dragStart");
        onKeyGrab?.();
        e.preventDefault();
      }
    }

    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
    };
  }, [onKeyGrab, onKeyMove, onKeyRelease, setGrabStatus, enabled]);

  function onBlur() {
    grabbed.current = false;
    onKeyRelease?.();
    setGrabStatus("drop");
  }

  return {target, onBlur};
}

이 훅은 컴포넌트가 마운트되면, keydown 이벤트를 마운트하고, 언마운트되면 keydown 이벤트를 마운트 해제해요. (의존성 배열에 onKeyGrab, onKeyMove, onKeyRelease, setGrabStatus가 있지만, setGrabStatus는 useState의 세터 함수고, onKeyGrab, onKeyMove, onKeyRelease 함수는 useCallback으로 감쌀 예정이므로, 저 함수들이 렌더링 도중 자주 바뀌지는 않아요.)

우선, grab이 false인 상태에서, 대상이 포커스되었을 때 space바를 누르면 grab을 한 상태로 취급하면서, onKeyGrab 함수를 호출해요. 이 상태에서, 방향키를 누르면 onKeyMove 함수를 호출해요. 이 때, 키보드가 눌린 상태와 실제 움직임의 관심사를 분리하기 위해, onKeyMove 함수에는 키보드와 매핑된 x, y좌표의 이동값을 넘겨주게 했어요. 마지막으로, space 바를 누르면 grab을 해제해요. 만약, 모종의 사유로 대상이 포커스가 해제된다면 onFocusOut 함수를 호출하도록 해요. 이 함수는 대상의 onBlur props로 넘길 수 있어요.

굳이 setGrabStatus라는 state setter를 넘겨줘야 할 필요가 있어요. setGrabStatus는 자막과 관련된 상태인데, 유니버설 아일랜드와 같이 드래그 가능한 요소가 2개 이상인 경우 자막의 상태를 여러 오브젝트에서 공유해야 하기 때문에, 자막의 상태를 두 개의 useA11yDrag 안에서 공유해야 해요.

const [subtitle, setSubtitle] = useState("");

const onKeyMove = useCallback(function (x, y) {
  setX((prev) => prev + x * 10);
  setY((prev) => prev + y * 10);
}, []);

const {target: handleRef, onBlur} = useA11yDrag({
  onKeyMove,
  enabled,
  setSubtitle,
});

return {
  ...,
  subtitle: getSubtitle(subtitle) 
}

이렇게 정의한 useA11yDrag 훅은 다음과 같이 사용할 수 있어요. 먼저, onKeyMove를 정의해서 키보드를 움직여 드래그 상태를 시뮬레이션할 때 생기는 동작을 정의해요. 위에서 존재하던 주행거리 인터랙션의 경우, 현재 x, y 좌표에 키보드를 입력한 값을 더해주었어요. 이후, 자막의 setter와 onKeyMove 등을 useA11yDrag 훅에 인자로 넣어요. 이후, 반환된 target ref와 onBlur 함수를 드래그 타겟인 엘리먼트에 부착하면 돼요.

이렇게 드래그 동작을 키보드로 제어할 수 있게 되면서, 마우스를 사용할 수 없는 환경이더라도 인터랙션을 수행할 수 있게 되었어요. 하지만, 저희는 "마우스를 못 쓰는 사람"뿐만 아니라, "시각장애인"이 인터랙션을 온전히 누리게 하는 것이 목표였어요.

aria-live="assertive"를 이용한 적절한 자막 제공

드래그 인터랙션을 다시 분석해 보면, "드래그를 시작 및 종료하는 트리거", "2차원 오브젝트의 위치 변경"과 더불어 "시각적 위치 변경 피드백"이 들어가 있는 것을 알 수 있어요. 저희는 드래그를 하면 시각적으로 현재 드래그 중인 요소가 어디에 있는지 확인할 수 있지만, 시각장애인은 키보드로 요소를 조작할 수 있다고 해도, 요소가 어디에 위치해 있고, 정말로 요소가 변화했는지에 대한 피드백이 존재하지 않아서, 사용성이 떨어진다는 문제가 있었어요. 따라서, 저희는 드래그를 시작할 때, 드래그를 할 때, 드래그를 종료할 때 액션에 대해 청각적인 피드백을 주어야 한다고 생각했어요.

<span class="assistive-text" aria-live="assertive">다이얼 드래그를 시작했습니다. 좌우 방향키로 시간을 줄이고 늘리세요. 현재 선택한 시간은 24분입니다.</span>

저희는 aria-live="assertive" 속성을 이용해서 청각적 피드백을 구현했어요. 일반적으로, 시각장애인이 사용하는 스크린 리더는 텍스트에 포커스가 위치해 있어야 글을 읽을 수 있지만, aria live region은 텍스트에 포커스가 위치해 있지 않아도, aria-live 속성이 존재하는 요소의 텍스트가 변경되면 스크린 리더가 읽는 텍스트의 큐에 텍스트를 끼워넣을 수 있어요. aria live region은 다음의 2개로 구성되어요.

  • aria-live="polite" : 스크린 리더의 텍스트 큐의 마지막에 텍스트를 끼워넣어요. 모든 텍스트가 다 읽힌 뒤에 자막을 읽어주는 형태라서, 현재 읽히는 청각 자막을 방해하지 않아요. 대부분의 경우는 이 속성을 사용해요.
  • aria-live="assertive" : 스크린 리더의 텍스트 큐의 처음에 텍스트를 끼워넣어요. 이미 텍스트를 읽고 있더라도, 즉각적으로 설정한 텍스트를 읽을 수 있어요. 인터랙션이나 게임과 같이 즉각적인 피드백을 줘야 할 때 유용해요.

저희의 드래그 인터랙션은 이미 읽고 있던 텍스트를 방해하지 않는 것보다는 즉각적으로 드래그하고 있는 것인지 피드백하는 것이 사용자 경험적으로 더 중요하다고 판단했기 때문에, aria-live="assertive"를 이용했어요.

function getSubtitle(subtitleState)
{
  case "dragStart": return `드래그가 시작되었습니다. 방향키로 오브젝트를 이동하고, 스페이스바로 오브젝트를 놓으세요. 현재 위치 : (${x}, ${y}).`;
  case "dragging": return `현재 위치 : (${x}, ${y}).`;
  case "drop": return `드래그가 종료되었습니다. 현재 위치 : (${x}, ${y}).`;
  return "";
}
//...
return {
  ...,
  subtitle: getSubtitle(subtitle) 
}

aria-live="assertive"를 이용한 자막은 어렵지 않게 구현할 수 있어요. 들어갈 자막의 내용을 상태로 간주하고, 드래그 시작할 때, 드래그 도중일 때, 드래그가 종료되었을 때 상태를 변경시키는 로직을 구성하면 되어요. 저희는 드래그를 시작할 때, 조작 방법과 현재 위치를 알려 주고, 드래그 도중과 드래그를 종료할 때 현재 위치와 드래그 상태를 알려주었어요. 이렇게 구성하면, 시각장애인도 드래그 인터랙션에 대한 피드백을 누릴 수 있어서, 시각장애인도 온전히 드래그 인터랙션을 누릴 수 있어요.

참조문헌

Clone this wiki locally