OTTER-LOG

Roving TabIndex 구현하기

Roving TabIndex 구현하기
by otter2022년 12월 14일에 최종수정되었습니다.
잘못된 내용이 있으면 댓글을 달아주세요.

link_preview

Toggle Group 컴포넌트를 구현하는 중, 접근성을 위해 keboard 이벤트를 적용하고 있었습니다. 그런데, Toggle 중 하나만을 선택하는 경우 마치 radio 처럼 작동하며 해당 부분의 tabIndex 를 조정해주어야 했고 w3c의 접근성가이드를 보니 Roving TabIndex 로 구현해야 했습니다.

Tabindex란?

tabindex 는 키보드의 tab 키를 눌렀을때의 포커스의 이동 순서를 조정할 수 있는 HTML 의 속성입니다. 예를 들어, 어떠한 HTML 요소의 tabindex1 의 값을 주었다면 부여된 요소가 가장 먼저 포커스 됩니다. 그러나, 이 tabindex 는 신중히 사용해야 합니다. 일반적으로 키보드의 tab 키를 눌렀을 때에 HTML 의 마크업 순서에 따라 포커스를 가질 수 있는 요소에 자연스럽게 이동하기 때문입니다. 오히려 잘못된 tabindex 의 값은 포커스의 순서에 혼란을 주어, 스크린 리더 사용자가 페이지를 이해하는데에 어려움을 가지게 될 가능성이 있습니다.

tabindex의 0과 -1

하지만 때때로 우리는 해당 요소의 포커스가 가게해야 할 때도 있고, 또는 포커스가 가지 않게 해야할 때도 있습니다. 이럴때 tabindex 에 0 또는 -1의 값을 주어 이를 진행할 수 있습니다.

  • tabindex = 0

    일반적으로 div , span 태그의 경우 focus 가 되지 않습니다. 그러나, 스크린리더 사용자를 위해 해당 부분에 포커스가 가야만 할 때에 tabindex0 의 값을 주어 해당 부분이 포커스가 가능하도록 할 수 있습니다.

  • tabindex = -1

    -1 의 값을 준다면, 해당 요소에 포커스가 가지 않도록 할 수 있습니다.


Roving Tabindex란?

roving tabindextabindex 를 관리하는 방법이라고 할 수 있습니다. 일반적으로, radio button 과 같은 컴포넌트에서 주로 사용되며, 다음과 같은 특징을 가집니다.

  • 컴포넌트가 로드 되면, 선택된 요소 또는 첫번째 요소의 tabindex = 0 이 됩니다.

    이 요소를 제외한 다른 형제 요소들은 tabindex = -1 이 되어 tab 으로 포커스할 수 없는 상태가 됩니다.

  • 위의 상황에서 arrow key 등을 통해 키보드 네비게이션을 진행할 수 있습니다.

  • 키보드 네비게이션을 통해 다음 요소로 이동한다면, (이동되어진) 다음 요소의 tabindex = 0 이 되며 이전에 tabindex0 이였던 요소는 tabindex = -1 이 되어 tab 으로 포커스 할 수 없어야 합니다.

이를, 컴포넌트로 확인하면 다음과 같이 동작해야 합니다.

세가지 토글이 존재하고, 이 중 한가지만을 선택 가능한 토글 그룹이 있다고 생각합시다.

위처럼 아무것도 선택되지 않을 때에는, tab 을 했을 때에 첫번째 요소에 포커스가 가야 합니다. 이후 첫번째 요소에서 tab 을 할 경우 value2 , value3 은 포커스 되지 않아야 합니다.

위 상황에서, 오른쪽 화살표키를 입력하면

위처럼 value2focus 가 가야 하며, valuetabIndex = -1 이 되어야 합니다. 이때 포커스 된 value2tabIndex = 0 이 되어야 합니다.

Roving Provider 구현하기

세가지 토글을 사용하는 토글 그룹은 이미, 어느정도 완성되었으므로 roving tabindex 를 구현할 수 있습니다. 그런데, roving tabindex 를 구현하기 위해서는 위 세개 토글 버튼의 DOM 에 직접 접근해야 합니다. 우리는 DOM 에 직접 접근해 tabindex 값을 계속해서 바꾸어주어야 하기 때문입니다.

또한, 오른쪽 화살표 키와 왼쪽 화살표 키를 통해서 포커스가 바뀌는 기능을 구현해야 하므로 키보드 이벤트를 추가해야 할 것입니다. 이를 구현할 때에 w3croving tabindex 를 보면 자동적으로 loop 로 진행되므로 마지막 요소에서 오른쪽을 눌렀을 때에 첫번째로 이동하는 기능 또한 필요할 것입니다.

마지막으로, 세가지 토글 요소중 이전에 선택된 요소가 있다면 해당 요소는 초깃값으로 tabindex = 0 의 값을 가지고 있어야 합니다.

위 세가지 요소들을 구현하기 위해, 체크리스트를 작성 합시다.

  1. DOM 에 직접 접근하기 위해, 세가지 요소들을 모아야 한다.
  2. 오른쪽, 왼쪽 키보드 이벤트를 통해 이를 탐색하고 tabIndex 를 수정해야 한다.
  3. 이전에 선택된 요소가 있다면, 렌더링 될때 초깃값으로 tabIndex = 0 이어야 한다.

자식 요소의 ref 모으기

자식요소의 DOM 에 접근하기 위해, ref 를 모으려면 다음과 같은 방법들이 존재합니다.

  1. 상위의 context API 에서 해당 ref 들을 모으는 방법
  2. 특정 attribute 를 선언해주고, document.querySelectorAll 을 사용하는 방법
  3. React.Children API 를 통해 재귀적으로 탐색하며 모으는 방법

이 중, 저는 첫번째 방법을 선택했습니다. 두번째 방법은, react 의 철학과는 조금 다르다고 생각했고 세번째 방식은 - 대부분의 경우에 그럴 경우가 없겠지만 - React.Fragment 가 존재할 시 탐색이 원활하게 되지 않았기 때문입니다.

따라서, 세번째 방식을 통해 아래와 같이 RovingProvider 를 선언해 주었습니다.

// 자식요소의 ref를 모으기 위해 커스터훅을 작성했습니다. // 이 커스텀 훅은 rovingProvider에서 사용가능합니다. const RovingProvider = (props: { children: React.ReactNode }) => { const { children } = props; const rovingItems = React.useRef(new Map()).current; const getItems = React.useCallback(() => { return Array.from(rovingItems.values()).filter((item) => !item.disabled); }, [rovingItems]); // Item들을 모아볼 수 있는 함수입니다. **const useRegister: RovingContext["useRegister"] = (id, props) => { const { dom, value, disabled, ...rest } = props; React.useEffect(() => { rovingItems.set(id, { dom, value, disabled, ...rest, }); return () => { rovingItems.delete(id); }; }, [value]); };** ..

이제, 우리는 이 useRegister 함수를 해당 ref 들을 모으고 싶은 컴포넌트에서 실행하면 ref 들을 모을 수 있습니다.

export const Item: Poly.Component<typeof DEFAULT_ITEM, ItemProps> = React.forwardRef( <T extends React.ElementType = typeof DEFAULT_ITEM>( props: Poly.Props<T, ItemProps>, ref: Poly.Ref<T>, ) => { const { children, value, disabled = false, ...restProps } = props; .... **const { useRegister } = useRoving(); useRegister(value, { dom: ItemRef, value, disabled, }); ...**

키보드 이벤트 설정하기

이제, dom 요소들을 모았으므로 keyboard 이벤트를 선언해야 합니다. keyboard 이벤트를 적용할 때에는, tabIndex 의 변화를 함수로 선언해 아래와 같이 진행할 수 있었습니다.

const handleRovingKeyDown = React.useCallback((e: React.KeyboardEvent) => { const { key } = e; const items = getItems().map((item) => item.dom.current); const currentFocusedIndex = getCurrentFocused(items); // document.activeElement를 사용해 현재 focus된 요소를 확인합니다. const currentFocusedNode = items[currentFocusedIndex]; const [nextIndex, prevIndex] = getComputedIndex( currentFocusedIndex, items.length, ); // 현재 focus된 요소를 알고 있으므로, 다음 또는 이전 요소의 index를 구할 수 있습니다. switch (true) { // ArrowRight, ArrowUp 의 키가 입력된다면 case ROVING_KEYS.NEXT.includes(key): const nextNode = items[nextIndex]; setNodeUnFocusable(currentFocusedNode); // 현재 포커스된 요소의 tabIndex = -1 setNodeFocusable(nextNode); // 다음 요소의 tabIndex = 0 setNodeFocus(nextNode); // 다음 요소를 포커스 합니다. break; ... case ROVING_KEYS.CLOSE.includes(key): { items.forEach(setNodeUnFocusable); // Tab키를 누를 경우, 모든 요소의 tabIndex를 -1로 합니다. // 이를 통해, Tab키를 누를 경우 tabIndex요소로 빠져나갈 수 있습니다. break; } default: break; } }, []);

위와 같은 키보드 이벤트를 넣어주는 방식을 통해, tabIndex 를 조절할 수 있었습니다.

TabIndex 초기화하기

다른 요소에서, roving 요소로 tab 을 통해 접근할 때 다음과 같은 규칙을 지켜야합니다.

  • checked 된 요소가 있다면, 해당 요소의 tabIndex = 0 나머지는, -1 이 되어야 합니다.
  • checked 된 요소가 없다면, 첫번째 요소의 tabIndex = 0 나머지는, -1 이 되어야 합니다.

이를 위해, roving 요소를 묶고 있는 group 요소에 focus 가 될때 아래와 같은 이벤트를 설정했습니다.

const handleRovingFocus: RovingContext["handleRovingFocus"] = React.useCallback( ({ dom, selected }) => (e: React.FocusEvent) => { if (e.target !== dom.current) return; // 러빙요소가 아닌 그룹 요소에 포커스 되어야 합니다. // 위와 같은 방식으로 제한해 주었습니다. const items = getItems(); const selectedIndex = Array.isArray(selected) ? items.findIndex((item) => selected.includes(item.value)) : items.findIndex((item) => item.value === selected); const computedIndex = selectedIndex === -1 ? 0 : selectedIndex; // checked된 요소가 있다면 해당 요소를 찾고 아니라면 0번째 요소를 target합니다. const targetNode = items[computedIndex].dom.current; items.forEach((item) => setNodeUnFocusable(item.dom.current)); // 모든 요소를 -1로 초기화 해준 뒤, setNodeFocusable(targetNode); setNodeFocus(targetNode); // target 요소의 tabIndex를 조정하고, focus 합니다. }, [], );

onfocus 는, group 에서 사용해 주었습니다. 개별 요소에 focus 를 넣어주게 되면, 매번 요소가 focus 될때마다 tabIndex 가 초기화되어 의도치 않은 방향으로 진행되었기 때문입니다. 따라서, group 요소임을 e.target과 dom 요소로 명확이 파악한 후, group 요소가 focus 된다면 이후 바로 이어 개별 요소의 tabIndex 를 초기화하는 방법으로 진행했습니다.

최종 코드는 여기서 확인해보실 수 있습니다.

ref