일반적으로, 아코디언 컴포넌트에 기대하는 키보드 이벤트는
여기서 이동할 수 있다는 것은 포커스를 이동할 수 있다는 의미입니다
일단은 각 아코디언 아이템에 키보드 이벤트를 바인딩 하는것보다는, 이벤트 위임을 사용하여 가장 최상위 태그에서 다룰 수 있게 하는 것을 목표로 진행했다
가장 쉬운 방법은 엔터가 눌려진 태그의 display 속성을 제어하는 것도 가능하겠지만,
현재 아코디언 아이템들은 상태에 따라 열리고 닫히고가 결정되고 있기 때문에, 이를 조작해야 한다
export const AccordionItem = ({ value, children }: AccordionItemProps) => {
const { selected, onItemOpen, onItemClose } = useAccordionContext("accordion")
const isOpen = selected?.includes(value)
const handleChange = () => {
isOpen ? onItemClose(value) : onItemOpen(value)
}
return (
<AccordionItemProvider
isOpen={!!isOpen}
onToggle={handleChange}
value={value}
>
<div
data-state={isOpen ? "open" : "close"}
>
{children}
</div>
</AccordionItemProvider>
)
}
현재 클릭 이벤트의 경우 onToggle 이라는 함수를 context를 통해 AccordionTrigger의 onClick에 바인딩하여 상태를 조작하고 있었는데
onToggle 만 실행시킬 수 있으면 된다하지만 현재 최상위 컴포넌트인 Accordion 의 keyDown 이벤트에서는 현재 AccordionItem 이 열려있는지 닫혀있는지를 판단할 수 없다
판단 기준인 value는 Item의 prop이기 때문에 이를 최상위 컴포넌트의 이벤트 함수에서 조회할 수가 없다
이벤트 객체인 e.target에는 접근할 수 있으니까dataset 속성같은 걸 이용하는 방법이 기존 코드를 안 건드는 최선의 방법일 것 같은데, DOM에 직접 접근하는 게 괜찮을지 아직 잘 모르겠다(but 이미 애니메이션 구현하느라 충분히 많이 접근하고 있다..)
const refs = useRef<HTMLElement[]>([])
<div
ref={(node) => {
accordionRefs.current = Array.from(node?.children || []) as HTMLElement[]
}}>{children}></div>
이렇게 되면 refs에는 각 자식 노드들이 배열로 들어오게 되고, focus 조절의 경우 상태의 영향을 받지 않기 때문에 최상위 컴포넌트에서 처리가 가능하다
useEffect 를 사용해도 무방export const useKeyboardEvent = ({
keyList,
changeIndex,
}: UseKeybaordEvent) => {
const refs = useRef<HTMLElement[]>([])
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>, callback?: () => void) => {
if (!keyList.includes(event.key)) return
const length = refs.current.length
const target = event.target as HTMLElement
const currentIndex = refs.current.indexOf(target)
event.preventDefault()
let nextIndex = 0
switch (event.key) {
case "ArrowDown":
nextIndex = (currentIndex + 1) % length
break
case "ArrowUp":
nextIndex = (currentIndex - 1 + length) % length
break
case "ArrowRight":
nextIndex = (currentIndex + 1) % length
break
case "ArrowLeft":
nextIndex = (currentIndex - 1 + length) % length
break
case "Home":
nextIndex = 0
break
case "End":
nextIndex = length - 1
break
case "Tab":
nextIndex = (currentIndex + 1) % length
break
case "Enter":
callback?.()
}
changeIndex?.(nextIndex)
refs.current[nextIndex]?.focus()
},
[],
)
return { refs, handleKeyDown }
}
이런식으로 관련된 이벤트들을 모아서 현재 children의 수 만큼 키보드 조작 시 포커스를 정해주는 hook을 가져와봤다
일단 아직까지는 AccordionItem,Trigger는 별 고민없이 div와 button을 사용하고 있었다
tab key를 적용하지 않았는데도 tab에 따라 포커스가 옮겨진다
handleKeyDown을 바인딩하고, e.target 을 조사해보니 AccordionItem의 div 가 아닌 AccordionTrigger의 button 이 찍힌다
원인
<input>, <textarea>, <button>, <a> 는 기본적으로 tabIndex값이 존재한다. 그렇기 때문에 따로 지정해주지 않아도 tab을 이용해서 포커스를 잡을 수 있던 것이다
당연히 이벤트가 발생한 태그 역시 버튼이기 때문에 e.target은 button 이었던 것이다
하지만 최상위의 refs에 담긴 children들은 AccordionItem의 div이기 때문에, e.target을 div가 찍히도록 할 수 있어야 한다
export const AccordionItem = ({ value, children }: AccordionItemProps) => {
const { selected, onItemOpen, onItemClose } = useAccordionContext("accordion")
const isOpen = selected?.includes(value)
const onToggle = () => {
isOpen ? onItemClose(value) : onItemOpen(value)
}
return (
<AccordionItemProvider isOpen={!!isOpen} onToggle={onToggle} value={value}>
<div
data-state={isOpen ? "open" : "close"}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") {
onToggle()
}
}}
>
{children}
</div>
</AccordionItemProvider>
)
}
export const AccordionTrigger = ({ children }: PropsWithChildren) => {
const { onToggle, isOpen, value } = useAccordionItemProvider("accordionItem")
return (
<h3 data-state={isOpen ? "open" : "close"}>
<button
onClick={onToggle}
aria-expanded={isOpen ? true : false}
aria-controls={value}
tabIndex={-1}
>
<span>{children}</span>
</button>
</h3>
)
}
AccordionItem) -> button(AccordionButton)으로 두번 탭이 포커스 되는 게 조금 어색해서 button의 tabIndex를 -1로 포커스 되지 않도록 변경했다tabIndex를 뭔가 인위적으로 조작하는게 맞나? 싶긴 하다. 다른 UI들과도 혼합되면 더 복잡해질 것 같기도 하고..
지금도 생각보다는 간단하게 해결한 것 같긴 한데, 다른 UI 라이브러리들을 보니까 아예 tabIndex 없이도 잘 돌아가는 것 같아서 많이 찾아봤는데, 명확한 해결법은 잘 모르겠다
아니면 그냥 이벤트 위임을 포기하는 게 더 나을을 것 같기도 하다(일일이 바인딩해주면 더 간단해서)
아코디언 아이템들이 엄청 많을 확률도 적고, 몇개 안되는 아이템들에 이벤트 바인딩했다고 성능에 큰 차이가 있을까? 너무 오버하는건 아닐까? 싶은 생각도 들고..
한번 날잡고 소스코드 분석을 한번 해봐야겠다