Accordion 만들기 - 키보드 이벤트

jh·2024년 7월 20일

디자인 시스템

목록 보기
8/14

일반적으로, 아코디언 컴포넌트에 기대하는 키보드 이벤트는

  • Tab 버튼을 통해 아코디언 아이템들을 이동할 수 있다
  • 화살표 버튼으로 아코디언 아이템들을 이동할 수 있다
  • 엔터 클릭 시 아코디언 아이템을 열고 닫을 수 있다

여기서 이동할 수 있다는 것은 포커스를 이동할 수 있다는 의미입니다

일단은 각 아코디언 아이템에 키보드 이벤트를 바인딩 하는것보다는, 이벤트 위임을 사용하여 가장 최상위 태그에서 다룰 수 있게 하는 것을 목표로 진행했다

엔터 이벤트 다루기

가장 쉬운 방법은 엔터가 눌려진 태그의 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 이미 애니메이션 구현하느라 충분히 많이 접근하고 있다..)

탭 이벤트

  • 최상위 컴포넌트에서 자식 컴포넌트들을 ref.current에 저장해놓고 이를 조작하는 방식을 선택해봤다
const refs = useRef<HTMLElement[]>([])

<div
   ref={(node) => {
          accordionRefs.current = Array.from(node?.children || []) as HTMLElement[]
        }}>{children}></div>

이렇게 되면 refs에는 각 자식 노드들이 배열로 들어오게 되고, focus 조절의 경우 상태의 영향을 받지 않기 때문에 최상위 컴포넌트에서 처리가 가능하다

  • ref callback을 이용했는데 , 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을 가져와봤다

tabIndex 관련 문제

일단 아직까지는 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의 div태그에 tabIndex를 준다
  • 엔터 이벤트도 AccordionItem에 바인딩해준다
  • 탭키를 눌렀을 때 하나의 아이템에서 부모 div(AccordionItem) -> button(AccordionButton)으로 두번 탭이 포커스 되는 게 조금 어색해서 button의 tabIndex를 -1로 포커스 되지 않도록 변경했다

후기

tabIndex를 뭔가 인위적으로 조작하는게 맞나? 싶긴 하다. 다른 UI들과도 혼합되면 더 복잡해질 것 같기도 하고..

지금도 생각보다는 간단하게 해결한 것 같긴 한데, 다른 UI 라이브러리들을 보니까 아예 tabIndex 없이도 잘 돌아가는 것 같아서 많이 찾아봤는데, 명확한 해결법은 잘 모르겠다

아니면 그냥 이벤트 위임을 포기하는 게 더 나을을 것 같기도 하다(일일이 바인딩해주면 더 간단해서)
아코디언 아이템들이 엄청 많을 확률도 적고, 몇개 안되는 아이템들에 이벤트 바인딩했다고 성능에 큰 차이가 있을까? 너무 오버하는건 아닐까? 싶은 생각도 들고..

한번 날잡고 소스코드 분석을 한번 해봐야겠다

0개의 댓글