React로 Accordion 컴포넌트 만들기 Accordion의 정의
여러 개의 항목을 한정된 공간에서 보여주고, 각 항목의 헤더를 클릭하면 해당 항목의 콘텐츠가 펼쳐지거나 접히는 UI 패턴.
Accordion의 동작 특성
아코디언 헤더 영역을 클릭하면 콘텐츠 영역을 확장하거나 축소시킬 수 있다.
Accordion의 구조
Accordion의 구조는 세가지 요소로 구성된다. Accordion, AccordionHead, AccordionBody이다.
Accordion
아코디언 컴포넌트의 메인 컴포넌트 역할.
아코디언의 동작을 제어하는 상태값을 하위 컴포넌트에 전달하는 역할.
AccordionHead
아코디언 헤더 영역을 표현하는 역할.
아코디언 헤더 영역을 클릭하면 아코디언 바디의 콘텐츠 영역을 확장하거나 축소시키는 역할.
AccordionBody
아코디언 바디 영역을 표현하는 역할.
Collapse 컴포넌트를 재사용하여 구현한다.
TIP
이전 포스트 를 참고하면 앞으로 다뤄질 내용을 이해하는데에 도움이 됩니다.
Accordion 컴포넌트의 마크업과 스타일링
메인 컴포넌트 Accordion 컴포넌트를 작성하자.
AccordionHead, AccordionBody와 연결된 컴파운드 컴포넌트로 만들어질 예정이다.
Accordion.tsx
1 'use client' ;
2 import { type PropsWithChildren } from 'react' ;
3
4 import clsx from 'clsx' ;
5
6 import AccordionBody from './AccordionBody.client' ;
7 import AccordionHead from './AccordionHead.client' ;
8
9 interface AccordionProps extends PropsWithChildren {
10 className ? : string ;
11 }
12
13 function Accordion ( ) {
14 return (
15 < div className = { clsx ( 'broccoli-ui-accordion' , className ) } > { children } < / div >
16 ) ;
17 }
18
19 export default Object . assign ( Accordion , {
20 Head : AccordionHead ,
21 Body : AccordionBody ,
22 } ) ;
스타일은 다음처럼 구성한다.
보더에 대한 표현은 디자이너랑 잘 논의해서 적용하도록 하자.
style.css
1 .broccoli-ui-accordion {
2 border : 1px solid oklch ( 84.52% 0 0 ) ;
3 }
AccordionHead 컴포넌트의 마크업과 스타일링
아코디언의 헤더 영역을 표시하는 AccordionHead이다.
루트 엘리먼트는 button 태그로 지정했다.
헤더를 클릭하더라도 접히거나 펼쳐지지 않도록 만들기 위해 disabled 속성을 사용하기 위함이다.
헤더 영역에 대한 표현은 children을 통해 문자열, 이미지 등, ReactNode가 될 수 있는 무엇이든 전달 가능하다.
화살표 아이콘의 회전을 통해 접힘/펼쳐짐을 표현한다.
AccordionHead.tsx
1 "use client" ;
2 import { type PropsWithChildren } from "react" ;
3
4 import clsx from "clsx" ;
5 import { IoIosArrowDown } from "react-icons/io" ;
6
7 export interface AccordionHeadProps extends PropsWithChildren {
8 classes ? : {
9 container ? : string ;
10 title ? : string ;
11 icon ? : string ;
12 } ;
13 }
14
15 export default function AccordionHead ( {
16 classes ,
17 children ,
18 } : AccordionHeadProps ) {
19
20 return (
21 < button className = { clsx ( "broccoli-ui-accordion-head" , classes ?. container ) } >
22 < div className = { clsx ( "broccoli-ui-accordion-head-title" , classes ?. title ) } >
23 { children }
24 < / div >
25 < IoIosArrowDown
26 className = { clsx (
27 "broccoli-ui-accordion-head-icon" ,
28 {
29 "broccoli-ui-accordion-head-icon--closed" : ! isOpen ,
30 "broccoli-ui-accordion-head-icon--opened" : isOpen ,
31 } ,
32 classes ?. icon ,
33 ) }
34 / >
35 < / button >
36 ) ;
37 }
스타일은 다음처럼 구성한다.
루트 엘리먼트에 지정되는 broccoli-ui-accordion-head 클래스는 flex 디스플레이 속성을 적용하고 수직 중앙 정렬을 적용한다.
children 입력을 통해 자유롭게 표현되는 타이틀 영역은 flex: 1을 통해 아이콘 영역을 제외한 나머지 영역을 차지하도록 한다.
아이콘 영역은 트랜지션 효과를 부여한다. transition-duration속성을 지정하지 않은 것은 Collapse컴포넌트를 구현했을 때와 마찬가지로 부모컴포넌트에서 전달하는 duration 프로퍼티 입력을 전달받아 처리되도록 한다.
그 이유는 트랜지션 효과가 부여되는 영역이 현재 두 곳이다. 헤더의 아이콘과 바디에 적용되는 콜랩스.
트랜지션 지속시간을 변경할 때 스타일시트의 여러 곳을 수정할지, 부모에서 단일 duration prop으로 제어할지를 고민했다.
style.css
1 .broccoli-ui-accordion-head {
2 display : flex ;
3 align-items : center ;
4 text-align : left ;
5 cursor : pointer ;
6 width : 100% ;
7 border : none ;
8 outline : none ;
9 padding : 0px ;
10 background-color : transparent ;
11 }
12
13 .broccoli-ui-accordion-head-title {
14 flex : 1 ;
15 }
16
17 .broccoli-ui-accordion-head-icon {
18 transition-property : transform ;
19 transition-timing-function : ease-in-out ;
20 }
21
22 .broccoli-ui-accordion-head-icon--opened {
23 transform : rotate ( 0deg ) ;
24 }
25
26 .broccoli-ui-accordion-head-icon--closed {
27 transform : rotate ( -180deg ) ;
28 }
AccordionBody 컴포넌트의 마크업과 스타일링
아코디언의 바디 영역을 표시하는 AccordionBody이다.
이 단락에서 중요한 부분은 Collapse 컴포넌트를 재사용하여 구현한다는 점이다.
하나의 컴포넌트를 잘 모듈화해두면, 이를 조합해 새로운 UI 모듈을 손쉽게 구성할 수 있다.
AccordionBody.tsx
1 "use client" ;
2 import { type PropsWithChildren , useContext } from "react" ;
3
4 import clsx from "clsx" ;
5
6 import Collapse from "../Collapse/index.client" ;
7
8 export interface AccordionBodyProps extends PropsWithChildren {
9 className ? : string ;
10 }
11
12 export default function AccordionBody ( {
13 className ,
14 children ,
15 } : AccordionBodyProps ) {
16
17 return (
18 < Collapse className = { clsx ( "broccoli-ui-accordion-body" , className ) } >
19 { children }
20 < / Collapse >
21 ) ;
22 }
React.Context를 사용하여 상태 공유하기
AccordionContext를 작성하자.
컨텍스트는 아코디언의 상태값을 하위 컴포넌트에 공유하게 된다.
AccordionContext.tsx
1 'use client' ;
2 import { createContext } from 'react' ;
3
4 import { type AccordionProps } from './index.client' ;
5
6 export interface AccordionContextProps extends AccordionProps {
7 toggle : ( ) => void ;
8 duration : number ;
9 }
10
11 export const AccordionContext = createContext < AccordionContextProps > ( {
12 isOpen : false , // 아코디언의 접힘/펼쳐짐 상태값
13 disabled : false , // 아코디언의 동작 비활성화 여부
14 duration : 250 , // 아코디언의 트랜지션 지속시간
15 toggle : ( ) => { } , // 아코디언의 접힘/펼쳐짐을 토글하는 함수
16 onEnter : ( ) => { } ,
17 onEntered : ( ) => { } ,
18 onExit : ( ) => { } ,
19 onExited : ( ) => { } ,
20 } ) ;
Accordion 컴포넌트의 기능 구현
컨텍스트를 연결하여 상태값이 공유되도록 만들자.
Line:35
아코디언 내부에 React.useState 훅을 통해 상태가 선언되어 있다.
이 훅은 부모 컴포넌트에서 전달받은 isOpenProp을 초기 상태값으로 사용한다.
처음부터 펼쳐져있거나 접힌 상태로 사용하고 싶은 경우에 대응하기 위함이다.
Line:37 ~ 41
React.useEffect 훅이 작성되어 있다.
이 처리는 아코디언이 내부 상태값을 통해 펼쳐지거나 접히는 동작을 하고 있는 상황에서 부모컴포넌트가 전달하는 isOpenProp 값이 변경된다면 그 값을 따라가도록 한다.
Accordion.tsx
1 'use client' ;
2 import { type PropsWithChildren , useEffect , useState } from 'react' ;
3
4 import clsx from 'clsx' ;
5
6 import type { CollapseProps } from '../Collapse/index.client' ;
7 import AccordionBody from './AccordionBody.client' ;
8 import AccordionHead from './AccordionHead.client' ;
9 import { AccordionContext } from './context.client' ;
10
11 export interface AccordionProps
12 extends PropsWithChildren <
13 Omit <
14 CollapseProps ,
15 'className' | 'isOpen' | 'children' | 'duration' | 'appear'
16 >
17 > {
18 className ? : string ;
19 isOpen ? : boolean ;
20 disabled ? : boolean ;
21 duration : number ;
22 }
23
24 function Accordion ( {
25 className ,
26 isOpen : isOpenProp = false ,
27 disabled = false ,
28 duration = 250 ,
29 children ,
30 onEnter ,
31 onEntered ,
32 onExit ,
33 onExited ,
34 } : AccordionProps ) {
35 const [ isOpen , setIsOpen ] = useState ( isOpenProp ) ;
36
37 useEffect ( ( ) => {
38 setIsOpen ( isOpenProp ) ;
39 } , [ isOpenProp ] ) ;
40
41 const toggle = ( ) => {
42 setIsOpen ( ( prev ) => ! prev ) ;
43 } ;
44
45 return (
46 < AccordionContext
47 value = { {
48 isOpen ,
49 disabled ,
50 duration ,
51 toggle ,
52 onEnter ,
53 onEntered ,
54 onExit ,
55 onExited ,
56 } }
57 >
58 < div className = { clsx ( 'broccoli-ui-accordion' , className ) } > { children } < / div >
59 < / AccordionContext >
60 ) ;
61 }
62
63 export default Object . assign ( Accordion , {
64 Head : AccordionHead ,
65 Body : AccordionBody ,
66 } ) ;
AccordionHead 컴포넌트의 기능 구현
React.useContext 훅을 사용하여 AccordionContext의 상태값을 참조하자.
버튼을 클릭했을 때 toggle함수를 호출하여 isOpen상태값이 토글되도록 한다.
duration은 transitionDuration스타일 속성에 연결한다.
화살표가 회전하는 트랜지션 지속시간과 Collapse 트랜지션 지속시간은 항상 동일하게 된다.
AccordionHead.tsx
1 import { AccordionContext } from "./context.client" ;
2
3 export interface AccordionHeadProps extends PropsWithChildren { }
4
5 export default function AccordionHead ( {
6 classes ,
7 children ,
8 } : AccordionHeadProps ) {
9 const { isOpen , disabled , duration , toggle } = useContext ( AccordionContext ) ;
10
11 const onClick = ( ) => {
12 toggle ( ) ;
13 } ;
14
15 return (
16 < button
17 className = { clsx ( "broccoli-ui-accordion-head" , classes ?. container ) }
18 disabled = { disabled }
19 onClick = { onClick }
20 >
21 < div className = { clsx ( "broccoli-ui-accordion-head-title" , classes ?. title ) } >
22 { children }
23 < / div >
24 < IoIosArrowDown
25 style = { { transitionDuration : ` ${ duration } ms ` } }
26 className = { clsx (
27 "broccoli-ui-accordion-head-icon" ,
28 {
29 "broccoli-ui-accordion-head-icon--closed" : ! isOpen ,
30 "broccoli-ui-accordion-head-icon--opened" : isOpen ,
31 } ,
32 classes ?. icon ,
33 ) }
34 / >
35 < / button >
36 ) ;
37 }
AccordionBody 컴포넌트의 기능 구현
React.useContext 훅을 사용하여 AccordionContext의 상태값을 참조하자.
Collapse가 제공하는 기능인 트랜지션 효과의 시작, 종료 함수에 대한 기능을 활용할 수 있다.
AccordionBody.tsx
1 "use client" ;
2 import { type PropsWithChildren , useContext } from "react" ;
3
4 import Collapse from "../Collapse/index.client" ;
5 import { AccordionContext } from "./context.client" ;
6
7 export interface AccordionBodyProps extends PropsWithChildren { }
8
9 export default function AccordionBody ( {
10 className ,
11 children ,
12 } : AccordionBodyProps ) {
13 const { isOpen , duration , onEnter , onEntered , onExit , onExited } =
14 useContext ( AccordionContext ) ;
15
16 return (
17 < Collapse
18 className = { clsx ( "broccoli-ui-accordion-body" , className ) }
19 isOpen = { isOpen }
20 duration = { duration }
21 onEnter = { onEnter }
22 onEntered = { onEntered }
23 onExit = { onExit }
24 onExited = { onExited }
25 >
26 { children }
27 < / Collapse >
28 ) ;
29 }
여기까지 아코디언 컴포넌트의 구현이 완료되었다.
다음처럼 동작하는 UI를 마주하게 된다.
Accordion
Lorem ipsum dolor sit amet consectetur adipisicing elit. Officia dolore tempora modi fuga reprehenderit, ad quae ullam molestias odio commodi laborum ratione a blanditiis, quibusdam hic quasi voluptas temporibus sequi.
어떻게 사용하죠?
아코디언 컴포넌트를 사용하는 관점으로 이동하자.
기본적인 형태는 다음처럼 작성할 수 있다.
부모컴포넌트에서 별도의 isOpen 플래그를 전달하지 않아도 내부 상태값을 사용하여 동작 할 것이다.
Page.tsx
1 export default function Page ( ) {
2 return (
3 < Accordion >
4 < Accordion . Head > Accordion < / Accordion . Head >
5 < Accordion . Body >
6 < p >
7 Lorem ipsum dolor sit amet consectetur adipisicing elit . Officia
8 dolore tempora modi fuga reprehenderit , ad quae ullam molestias odio
9 commodi laborum ratione a blanditiis , quibusdam hic quasi voluptas
10 temporibus sequi .
11 < / p >
12 < / Accordion . Body >
13 < / Accordion >
14 ) ;
15 }
Accordion
Lorem ipsum dolor sit amet consectetur adipisicing elit. Officia dolore tempora modi fuga reprehenderit, ad quae ullam molestias odio commodi laborum ratione a blanditiis, quibusdam hic quasi voluptas temporibus sequi.
아코디언 헤더를 클릭해야만 토글할 수 있는 것은 아니다.
부모 컴포넌트의 다른 영역에서 발생하는 이벤트에 따라 아코디언이 동작해야 할 수 있다.
1 export default function Page ( ) {
2 const [ isOpen , setIsOpen ] = useState ( false ) ;
3
4 return (
5 < >
6 < Accordion isOpen = { isOpen } >
7 < Accordion . Head > Accordion < / Accordion . Head >
8 < Accordion . Body >
9 < p >
10 Lorem ipsum dolor sit amet consectetur adipisicing elit . Officia
11 dolore tempora modi fuga reprehenderit , ad quae ullam molestias odio
12 commodi laborum ratione a blanditiis , quibusdam hic quasi voluptas
13 temporibus sequi .
14 < / p >
15 < / Accordion . Body >
16 < / Accordion >
17 < button type = "button" onClick = { ( ) => setIsOpen ( ! isOpen ) } >
18 { isOpen ? '닫아줘' : '열어줘' }
19 < / button >
20 < / >
21 ) ;
22 }
Accordion
Lorem ipsum dolor sit amet consectetur adipisicing elit. Officia dolore tempora modi fuga reprehenderit, ad quae ullam molestias odio commodi laborum ratione a blanditiis, quibusdam hic quasi voluptas temporibus sequi.
열어줘
Collapse가 제공하는 인터페이스를 활용하여 트랜지션 콜백 함수를 사용할 수 있다.
Page.tsx
1 export default function Page ( ) {
2 return (
3 < Accordion
4 onEnter = { ( ) => alert ( 'onEnter' ) }
5 onEntered = { ( ) => alert ( 'onEntered' ) }
6 onExit = { ( ) => alert ( 'onExit' ) }
7 onExited = { ( ) => alert ( 'onExited' ) }
8 >
9 < Accordion . Head > Accordion < / Accordion . Head >
10 < Accordion . Body >
11 < p >
12 Lorem ipsum dolor sit amet consectetur adipisicing elit . Officia
13 dolore tempora modi fuga reprehenderit , ad quae ullam molestias odio
14 commodi laborum ratione a blanditiis , quibusdam hic quasi voluptas
15 temporibus sequi .
16 < / p >
17 < / Accordion . Body >
18 < / Accordion >
19 ) ;
20 }
Accordion
Lorem ipsum dolor sit amet consectetur adipisicing elit. Officia dolore tempora modi fuga reprehenderit, ad quae ullam molestias odio commodi laborum ratione a blanditiis, quibusdam hic quasi voluptas temporibus sequi.
내용은 여기까지다. AccordionBody 영역을 Collapse 컴포넌트를 재사용하여 구현하는 부분이 이 포스트에서 전달하고 싶은 내용의 핵심이다. Atomic한 모듈 단위의 컴포넌트 리소스가 많아질 수록 이것들을 마치 레고 블럭처럼 조립하여 새로운 컴포넌트를 빠른 시간 내에 만들어낼 수 있게 된다.