DRAKE

RESUME

React에서 UI 라이브러리를 사용하지 않고 Accordion 만들기

2024/04/07

11 min read

DEVELOPMENT

REACT

DESIGNSYSTEM

thumbnail

이전에 작성했던 Tab을 생성하는 방법에 대해서 많은 관심이 있는 것 같아서 또 다른 컴포넌트를 만드는 방법에 대해서 작성을 하려고 합니다.

pandacss로 스타일링을 하고 있습니다. 참고 부탁드립니다.

Accordion이란

ChakraUI
ChakraUI

다음과 같은 움직임을 가지는 컴포넌트를 사용해보셨거나 많이 보셨을것 같아요 detail, summary 태그를 이용해서 만들 수 있지만 커스텀이 불편하기 때문에 직접 만들어 보도록 할게요!

추가 기능

요소에 아이템을 여러개를 정의해두었을 때 한 개만 열리게 할 것인지 여러개를 열리게 할 것인지 옵션으로 설정할 수 있어야한다

Accordion Provider

먼저 Accordion에서 사용할 값들을 공유해주기 위해서 context API를 통해 Provider를 만들어 줄게요.

공유되어야할 값은 다음과 같아요.

  1. type(open 타입)
  2. openIndex(열려있는 요소)
  3. handleChangeIndex(업데이트 함수)

1

const AccordionContext = createContext<AccordionState | null>(null);

2

3

const Accordion = ({ type = 'single', children, ...props }: AccordionProps) => {

4

const [openIndex, setOpenIndex] = useState<Set<number>>(new Set());

5

const childrenArray = Children.toArray(children);

6

7

const handleChangeIndex = useCallback(

8

(index: number) => {

9

if (type === 'single') {

10

if (openIndex === index) {

11

setOpenIndex(0);

12

return;

13

}

14

setOpenIndex(index);

15

return;

16

}

17

18

if (type === 'multiple') {

19

const newOpenIndex = new Set([...(openIndex as Set<number>)]);

20

21

if (newOpenIndex.has(index)) {

22

newOpenIndex.delete(index);

23

setOpenIndex(newOpenIndex);

24

return;

25

}

26

27

newOpenIndex.add(index);

28

setOpenIndex(newOpenIndex);

29

}

30

},

31

[openIndex, type],

32

);

33

34

return (

35

<AccordionContext.Provider value={{ type, openIndex, handleChangeIndex }}>

36

<div>

37

{childrenArray.map((child, id) => cloneElement(child as ReactElement, { index: id + 1 }))}

38

</div>

39

</AccordionContext.Provider>

40

);

41

};

현재 Accordion이 어느것이 열리는지 알기위한 openIndex 상태값을 만들어 줬어요 Set자료구조를 통해서 열린 Accordion을 저장해요.

선택한 Accordion의 값을 업데이트 하기 위해서 handleChangeIndex 함수를 만들어 주어야해요. 이미 있으면 삭제를 없으면 추가해줘요.

각 AccordionItem들에 대해서 번호를 매겨주어야하기 때문에 children을 순회하면서 cloneElement를 이용하여 index번호를 1부터 시작하도록 하고 넘겨주어 생성하도록 해줄게요

context 값을 쉽게 가져오기 위해서 다음과 같이 hook을 만들어 줄게요

1

export const useAccordionState = () => {

2

const context = useContext(AccordionContext);

3

if (!context) {

4

throw new Error('useAccordionState must be used within a Accordion');

5

}

6

return context;

7

};

AccordionItem

Accordion 메뉴를 생성할 wrap 컴포넌트입니다.

웹 접근성 향상과 group을 하기위해서 id값을 생성하여 하위 컴포넌트에게 공유하고 index값을 전달하여 렌더링하는 역할을 해요

1

const AccordionItem = ({ index, children }: AccordionItemProps) => {

2

const childrenArray = Children.toArray(children);

3

const id = useId();

4

5

const AccordionButtonComp = childrenArray.find(

6

(child) => isValidElement(child) && child.type === AccordionButton,

7

);

8

const AccordionPanelComp = childrenArray.find(

9

(child) => isValidElement(child) && child.type === AccordionPanel,

10

);

11

12

return (

13

<div>

14

{cloneElement(AccordionButtonComp as ReactElement, { index, id })}

15

{cloneElement(AccordionPanelComp as ReactElement, { index, id })}

16

</div>

17

);

18

};

간단하게 Accordion을 생성하는데 필요한 Button과 Panel에 대해서 가져오고 순서배치를 하기 위해서 해당 요소들을 찾아내어 순서에 맞게 렌더링합니다. 하위 요소도 Item과 같은 index값과 id값을 가져야 하기 때문에 props로 값을 넘겨주게 됩니다.

AccordionButton

Accordion을 열고 닫기 위한 버튼이예요

간단하게 버튼을 눌렀을 때 context API에 저장되는 현재 열려있는 값을 업데이트해주는 역할이예요

1

interface AccordionButtonProps {

2

index?: number;

3

id?: string;

4

children: React.ReactNode;

5

}

6

7

const AccordionButton = ({ children, index, id }: AccordionButtonProps) => {

8

const { openIndex, handleChangeIndex, type } = useAccordionState();

9

const isOpen = type === 'single' ? openIndex === index : (openIndex as Set<number>).has(index!);

10

11

return (

12

<h3>

13

<button

14

className={css({

15

width: '100%',

16

display: 'flex',

17

gap: '8px',

18

border: 'none',

19

padding: '16px 0',

20

backgroundColor: 'transparent',

21

cursor: 'pointer',

22

position: 'relative',

23

alignItems: 'center',

24

})}

25

onClick={() => {

26

handleChangeIndex(index!);

27

}}

28

id={id}

29

aria-expanded={isOpen}

30

aria-controls={id}

31

>

32

{children}

33

<div

34

className={css({

35

transition: 'transform 0.2s ease-in',

36

transform: isOpen ? ' rotate(180deg)' : 'rotate(0)',

37

})}

38

>

39

<Icon icon='chevron-down' width={20} height={20} iconColor='gray.300' />

40

</div>

41

</button>

42

</h3>

43

);

44

};

h태그로 감싸주어 해당 accordion의 제목이 무엇인지 나타내주었어요

상위 요소에서 받은 index값과 context API값에 있는 선택된 index값과 비교하여 현재 열려있는지 열려있지 않은지 확인하여 isOpen이라는 변수에 저장하고, isOpen의 값에 따라서 chevron을 상태에 따라서 방향을 돌려주고 있어요

접근성을 위해서 aria-expaned를 통해 열려있는지 열려있지않은지 상태를 알려주고 aria-controles를 이용하여 어떤 panel을 컨트롤하고 있는지도 명시해줄게요

button을 클릭하면 해당 Accordion의 index값을 context API로 공유되는 handleChangeIndex 함수를 통해서 값을 업데이트 해주게 돼요

Accordion Panel

Accordion의 내용을 담고있는 panel이예요.

1

interface AccordionPanelProps {

2

index?: number;

3

id?: string;

4

children: React.ReactNode;

5

}

6

7

const AccordionPanel = ({ children, index, id }: AccordionPanelProps) => {

8

const { openIndex, type } = useAccordionState();

9

const isOpen = type === 'single' ? openIndex === index : (openIndex as Set<number>).has(index!);

10

11

return (

12

<div

13

className={css({

14

display: isOpen ? 'block' : 'none',

15

})}

16

>

17

<div

18

ref={panelRef}

19

aria-labelledby={id}

20

role='region'

21

className={css({ paddingBottom: '16px' })}

22

>

23

{children}

24

</div>

25

</div>

26

);

27

};

AccordionButton과 동일하게 isOpen 변수에 현재 상태값을 저장하고 있어요. isOpen일 때 panel을 나타내고 isOpen이 아니라면 요소를 숨깁니다.

button에서 aria를 통해서 지정했던 값과 연결하기 위해서 aria-labelledby값을 할당해주고 있어요

모든 Accordion 작업을 완료했으니 정상적으로 동작하지 않을까요?

이제 사용을 해볼께요

1

<Accordion type='single'>

2

<AccordionItem>

3

<AccordionButton>열기</AccordionButton>

4

<AccordionPanel>내용물</AccordionPanel>

5

</AccordionItem>

6

<AccordionItem>

7

<AccordionButton>열기2</AccordionButton>

8

<AccordionPanel>내용물</AccordionPanel>

9

</AccordionItem>

10

<AccordionItem>

11

<AccordionButton>열기3</AccordionButton>

12

<AccordionPanel>내용물</AccordionPanel>

13

</AccordionItem>

14

</Accordion>

동작은 원하는대로 되고 있네요 하지만 동작하는 모습을 보면 딱딱 끊어져 보이기 때문에 부드럽게 변경이 되었으면 좋겠네요 부드럽게 동작하도록 변경을 해보도록 할게요

부드럽게 동작하게 만들기

animation 생성하기

animation 사용을 위해서 keyframe를 생성해줄게요

아코디언이 닫힐 때와 열릴 때를 만들어 주어야해요

1

accordionDown: {

2

from: { height: 0 },

3

to: { height: 'var(--accordion-height)' },

4

},

5

accordionUp: {

6

from: { height: 'var(--accordion-height)' },

7

to: { height: 0 },

8

},

open 여부에 따라서 css변수에 저장한 높이만큼 height를 변경해주는 keyframe이예요.

주의

저는 pandacss이기 때문에 css 변수를 이용해야 작업이 가능하기 때문에 해당 코드를 작성하였어요 스타일링에 따라 작업을 해주세요!

높이 구하기

요소의 높이가 얼마인지 알 수 없기 때문에 ref를 통해서 내용물이 들어있는 요소의 크기를 구해야해요

1

const { openIndex, type } = useAccordionState();

2

const isOpen = type === 'single' ? openIndex === index : (openIndex as Set<number>).has(index!);

3

const panelRef = useRef<HTMLDivElement>(null);

4

5

useEffect(() => {

6

if (isOpen && panelRef.current) {

7

panelRef!.current!.parentElement!.style.display = 'block';

8

const height = panelRef.current.style.getPropertyValue('--accordion-height');

9

10

if (height === '0' || !height) {

11

panelRef.current.parentElement!.style.setProperty(

12

'--accordion-height',

13

`${panelRef.current.clientHeight}px`,

14

);

15

}

16

}

17

}, [isOpen]);

18

19

return (

20

<div

21

className={css({

22

display: 'none',

23

})}

24

>

25

<div

26

ref={panelRef}

27

aria-labelledby={id}

28

role='region'

29

className={css({ paddingBottom: '16px' })}

30

>

31

{children}

32

</div>

33

</div>

34

);

display:'none'일 때는 높이를 알 수 없기 떄문에 isOpen여부에 따라서 display:'block'을 해준뒤 --accordion-height의 값을 가져와 값이 0이거나 없을 때 panelRef를 통해서 높이를 가져와 값을 셋팅해주고 있어요

animation 적용하기

높이도 구했고 keyframe도 만들었으니 적용만 하면 끝날 것 같아요

1

useEffect(() => {

2

if (isOpen && panelRef.current) {

3

panelRef!.current!.parentElement!.style.display = 'block';

4

const height = panelRef.current.style.getPropertyValue('--accordion-height');

5

6

if (height === '0' || !height) {

7

panelRef.current.parentElement!.style.setProperty(

8

'--accordion-height',

9

`${panelRef.current.clientHeight}px`,

10

);

11

}

12

}

13

14

if (!isOpen && panelRef.current) {

15

setTimeout(() => {

16

panelRef!.current!.parentElement!.style.display = 'none';

17

}, 150);

18

}

19

}, [isOpen]);

20

21

return (

22

<div

23

className={css({

24

display: 'none',

25

overflow: 'hidden',

26

transition: 'all 0.2s cubic-bezier(.4,0,.2,1)',

27

animation: isOpen

28

? 'accordionDown 0.2s cubic-bezier(.4,0,.2,1)'

29

: 'accordionUp 0.2s cubic-bezier(.4,0,.2,1)',

30

})}

31

>

32

<div

33

ref={panelRef}

34

aria-labelledby={id}

35

role='region'

36

className={css({ paddingBottom: '16px' })}

37

>

38

{children}

39

</div>

40

</div>

41

);

애니메이션을 isOpen에 따라서 위에서 만들었던 animation을 변경하고 isOpen이 아닐 때 애니메이션보다 조금 일찍 display:'none'처리를 해줍니다.

완성

이제 테스트를 해볼까요?

type single
type single

type multiple
type multiple
profile

한동룡