Roving TabIndex 구현하기
Tabindex란?
tabindex
는 키보드의 tab
키를 눌렀을때의 포커스의 이동 순서를 조정할 수 있는 HTML
의 속성입니다. 예를 들어, 어떠한 HTML
요소의 tabindex
에 1
의 값을 주었다면 부여된 요소가 가장 먼저 포커스 됩니다. 그러나, 이 tabindex
는 신중히 사용해야 합니다. 일반적으로 키보드의 tab
키를 눌렀을 때에 HTML
의 마크업 순서에 따라 포커스를 가질 수 있는 요소에 자연스럽게 이동하기 때문입니다. 오히려 잘못된 tabindex
의 값은 포커스의 순서에 혼란을 주어, 스크린 리더 사용자가 페이지를 이해하는데에 어려움을 가지게 될 가능성이 있습니다.
tabindex의 0과 -1
하지만 때때로 우리는 해당 요소의 포커스가 가게해야 할 때도 있고, 또는 포커스가 가지 않게 해야할 때도 있습니다. 이럴때 tabindex
에 0 또는 -1의 값을 주어 이를 진행할 수 있습니다.
-
tabindex = 0
일반적으로
div
,span
태그의 경우focus
가 되지 않습니다. 그러나, 스크린리더 사용자를 위해 해당 부분에 포커스가 가야만 할 때에tabindex
에0
의 값을 주어 해당 부분이 포커스가 가능하도록 할 수 있습니다. -
tabindex = -1
-1
의 값을 준다면, 해당 요소에 포커스가 가지 않도록 할 수 있습니다.
Roving Tabindex란?
roving tabindex
는 tabindex
를 관리하는 방법이라고 할 수 있습니다. 일반적으로, radio button
과 같은 컴포넌트에서 주로 사용되며, 다음과 같은 특징을 가집니다.
-
컴포넌트가 로드 되면, 선택된 요소 또는 첫번째 요소의
tabindex = 0
이 됩니다.이 요소를 제외한 다른 형제 요소들은
tabindex = -1
이 되어tab
으로 포커스할 수 없는 상태가 됩니다. -
위의 상황에서
arrow key
등을 통해 키보드 네비게이션을 진행할 수 있습니다. -
키보드 네비게이션을 통해 다음 요소로 이동한다면, (이동되어진) 다음 요소의
tabindex = 0
이 되며 이전에tabindex
가0
이였던 요소는tabindex = -1
이 되어tab
으로 포커스 할 수 없어야 합니다.
이를, 컴포넌트로 확인하면 다음과 같이 동작해야 합니다.
세가지 토글이 존재하고, 이 중 한가지만을 선택 가능한 토글 그룹이 있다고 생각합시다.
위처럼 아무것도 선택되지 않을 때에는, tab
을 했을 때에 첫번째 요소에 포커스가 가야 합니다. 이후 첫번째 요소에서 tab
을 할 경우 value2 , value3
은 포커스 되지 않아야 합니다.
위 상황에서, 오른쪽 화살표키를 입력하면
위처럼 value2
에 focus
가 가야 하며, value
의 tabIndex = -1
이 되어야 합니다. 이때 포커스 된 value2
는 tabIndex = 0
이 되어야 합니다.
Roving Provider 구현하기
세가지 토글을 사용하는 토글 그룹은 이미, 어느정도 완성되었으므로 roving tabindex
를 구현할 수 있습니다. 그런데, roving tabindex
를 구현하기 위해서는 위 세개 토글 버튼의 DOM
에 직접 접근해야 합니다. 우리는 DOM
에 직접 접근해 tabindex
값을 계속해서 바꾸어주어야 하기 때문입니다.
또한, 오른쪽 화살표 키와 왼쪽 화살표 키를 통해서 포커스가 바뀌는 기능을 구현해야 하므로 키보드 이벤트를 추가해야 할 것입니다. 이를 구현할 때에 w3c
의 roving tabindex
를 보면 자동적으로 loop
로 진행되므로 마지막 요소에서 오른쪽을 눌렀을 때에 첫번째로 이동하는 기능 또한 필요할 것입니다.
마지막으로, 세가지 토글 요소중 이전에 선택된 요소가 있다면 해당 요소는 초깃값으로 tabindex = 0
의 값을 가지고 있어야 합니다.
위 세가지 요소들을 구현하기 위해, 체크리스트를 작성 합시다.
DOM
에 직접 접근하기 위해, 세가지 요소들을 모아야 한다.- 오른쪽, 왼쪽 키보드 이벤트를 통해 이를 탐색하고
tabIndex
를 수정해야 한다. - 이전에 선택된 요소가 있다면, 렌더링 될때 초깃값으로
tabIndex = 0
이어야 한다.
자식 요소의 ref
모으기
자식요소의 DOM
에 접근하기 위해, ref
를 모으려면 다음과 같은 방법들이 존재합니다.
- 상위의
context API
에서 해당ref
들을 모으는 방법 - 특정
attribute
를 선언해주고,document.querySelectorAll
을 사용하는 방법 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
를 초기화하는 방법으로 진행했습니다.
최종 코드는 여기서 확인해보실 수 있습니다.