Accordion 컴포넌트 만들기

jh·2024년 7월 18일

디자인 시스템

목록 보기
9/14

참고자료
Radix UI - Accordion

Accordion이란

아코디언 UI를 보고 사람들이 기대하는 기능은

  • 화살표 버튼같은 어떤 버튼을 눌렀을 때 밑에 내용이 펼쳐졌다가, 다시 누르면 접힌다
  • 펼치거나 접을 때 내용이 서서히 등장했다가 사라진다

이걸 개발쪽으로 생각해보면

  • 버튼을 눌렀을 때 밑의 내용을 열건지, 닫을건지를 판단할 수 있어야 한다
  • 애니메이션을 통해 내용을 서서히 보여주거나 사라지게 만들어야 한다

An accordion is a vertically stacked set of interactive headings that each contain a title, content snippet, or thumbnail representing a section of content. The headings function as controls that enable users to reveal or hide their associated sections of content. Accordions are commonly used to reduce the need to scroll when presenting multiple sections of content on a single page.

W3C에서 설명하는 Accordion 또한 비슷한데,

  • 각각 제목, 내용 요약, 또는 섹션의 내용을 나타내는 썸네일을 포함하는 인터랙티브한 제목들이 수직으로 쌓인 형태입니다
  • 이 제목들은 제어 요소로서 작동하여 사용자들이 해당 섹션의 내용을 드러내거나 숨길 수 있게 한다
  • 일반적으로 여러 섹션의 내용을 하나의 페이지에 제시할 때 스크롤의 필요성을 줄이기 위해 사용됩니다

오픈소스 분석

radix UI

  • 하나의 컴포넌트에서 전체 아코디언 요소 중에 한개만 선택할 수 있게 하거나, 여러개를 선택할 수 있는 경우를 둘다 다루고 있다
  • 선택된 값을 외부에서 주입하거나(controlled) 내부에서 관리되는(uncontrolled) 두 가지 경우를 모두 지원한다
  • keyboard 이벤트 처리(arrow key로 아코디언 사이를 이동한다던가, 엔터키로 펼치거나 접을 수 있다던가..)

구현하기

radix 이외에도 여러 자료를 살펴보니 보통 이런 식으로 컴포넌트를 나눠놓는다

Accordion : 아코디언들을 다 모아놓는 wrapper의 역할

AccordionItem : 열고 닫히는 아코디언 한개를 나타낸다
- AccordionTrigger : 아코디언의 열림/닫힘을 제어하는 역할
- AccordionContent : 실제 내용이 있는 곳으로, Trigger에 의해 보일지 말지가 결정됨

예시

<Accordion type="single">
        <AccordionItem value="1">
          <AccordionTrigger>누르면 content가 열고닫힘</AccordionTrigger>
          <AccordionContent>내용임</AccordionContent>
        </AccordionItem>
        <AccordionItem value="2">
          <AccordionTrigger>누르면 content가 열고닫힘</AccordionTrigger>
          <AccordionContent>내용임</AccordionContent>
        </AccordionItem>
        <AccordionItem value="3">
          <AccordionTrigger>누르면 content가 열고닫힘</AccordionTrigger>
          <AccordionContent>내용임</AccordionContent>
        </AccordionItem>
</Accordion>

Accordion

export type TAccordionContext = {
  selected?: string[]
  onItemOpen: (value: string) => void
  onItemClose: (value: string) => void
}

const [AccordionProvider, useAccordionContext] =
  createContext<TAccordionContext>("accordion")

export interface AccordionProps extends PropsWithChildren {
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
  type?: "single" | "multi"
}
//ref 관련 문제 해결 필요
export const Accordion = forwardRef<HTMLElement, AccordionProps>((props, _) => {
  const { type = "single", ...accordionProps } = props
  if (type === "single") {
    return <SingleAccordion {...accordionProps} />
  }
  if (type === "multi") {
    return <MultiAccordion {...accordionProps} />
  }
})

가장 최상위 태그에서 할 일이 무엇일까 생각해보면

  • 선택된 아코디언들에 대한 데이터를 최상위 컴포넌트에서 관리하면서, context 를 통해 Item들로 내려주는 게 맞는 방법이라는 생각이 들었다

  • 단일/다중 선택에 관한 것도 prop을 통해 주입받아야 한다

export interface AccordionProps extends PropsWithChildren {
  value?: string[]//선택된 아코디언 value값
  defaultValue?: string[]//기본값
  onValueChange?: (value: string[]) => void//아코디언을 클릭했을 때 
  type?: "single" | "multi"
}
  • value는 현재 선택된(open 상태인) 아코디언이 어떤 것인지를 알기 위해 각 AccordionItemvalue 값을 저장한다

한 컴포넌트에서 단일/다중 선택을 다루려면 value가 string | string[] 타입이 되는게 원칙상 맞겠지만 그냥 string[] 으로 통일해서 단일 타입일 경우 배열을 한칸만 사용하는 것이 더 간단하게 해결할 수 있을 것 같았다

  • radix UI의 경우 type이 무엇인지에 따라 value의 타입을 다르게 하는 방식을 채택하고 있는데,
    이렇게 하면 ts에서 타입을 유추하는 속도가 느려지기도 하고 복잡해질 것 같아 선택하지 않았다(사실 완벽하게 이해를 못해서 안하는게 나을것 같다는 생각)

아코디언에 클릭 이벤트가 발생했을 때 type에 따라 하는일이 달라져야 하기 때문에

export const Accordion = forwardRef<HTMLElement, AccordionProps>((props, _) => {
  const { type = "single", ...accordionProps } = props
  if (type === "single") {
    return <SingleAccordion {...accordionProps} />
  }
  if (type === "multi") {
    return <MultiAccordion {...accordionProps} />
  }
})

이런 식으로 SingleAccordion , MultiAccordion으로 나누어 렌더링한다

interface AccordionImplSingleProps extends PropsWithChildren {
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
}

const SingleAccordion = ({
  value,
  defaultValue = [],
  onValueChange,
  children,
}: AccordionImplSingleProps) => {
  const [selected = [], setSelected] = useControlledState({
    prop: value,
    defaultProp: defaultValue,
    onChange: onValueChange,
  })
  return (
    <AccordionProvider
      selected={selected}
      onItemOpen={(value) => setSelected((_) => [value])}
      onItemClose={() => setSelected((_) => [])}
    >
      {children}
    </AccordionProvider>
  )
}
  • useControlledState hook을 통해 외부에서 value를 주입하게 되면 controlled 로, 아니면 uncontrolled 로 동작되게 설정했고,
    하나만 선택할 수 있기 때문에 항상 value는 길이가 0이거나 1인 경우만 있도록 설정했다
interface AccordionImplMultiProps extends PropsWithChildren {
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
}

const MultiAccordion = ({
  value,
  defaultValue,
  onValueChange,
  children,
}: AccordionImplMultiProps) => {
  const [selected, setSelected] = useControlledState({
    prop: value,
    defaultProp: defaultValue,
    onChange: onValueChange,
  })

  const handleItemOpen = useCallback(
    (item: string) => {
      setSelected((prev = []) => [...prev, item])
    },
    [setSelected],
  )

  const handleItemClose = useCallback(
    (item: string) =>
      setSelected((prev = []) => prev.filter((value) => value !== item)),
    [setSelected],
  )

  return (
    <AccordionProvider
      selected={selected}
      onItemOpen={handleItemOpen}
      onItemClose={handleItemClose}
    >
      {children}
    </AccordionProvider>
  )
}

single과 달리 다중 선택이 가능해야 하기 때문에 onItemOpen,onItemClose 함수만 변경했고, 나머지는 다 동일하다

  • 사실 굳이 Single, Multi로 컴포넌트로 나누지 않아도 되긴 하지만 복잡한 로직 안에 분기처리가 생기는 게 더 복잡해질 것 같아서 컴포넌트를 나누었다

0개의 댓글