React로 Collapse 컴포넌트 만들기

Collapse의 정의

  • 콘텐츠 영역의 확장(Expand) 과 축소(Collapse) 를 전환할 수 있는 UI 컴포넌트.
  • 주로 긴 텍스트, 상세 정보, FAQ 섹션 등 사용자가 필요할 때만 내용을 확인할 수 있도록 도와주는 데 사용된다.
collapse

Collapse의 동작 특성

  • 콘텐츠 영역을 확장하거나 축소시킬 수 있다.
  • 콘텐츠 영역의 크기가 변화하면 주변 레이아웃의 위치도 변경된다.

Collapse의 구조

react-transition-group 패키지의 CSSTransition 컴포넌트를 사용한다.

하나의 div 엘리먼트를 사용하여 트랜지션 상황에 맞는 height 속성을 적용하여 동작한다.

INFO

react-transition-group 문서의 CSSTransition 컴포넌트 설명을 읽어보면 구현을 이해하는데에 도움이 됩니다.

Collapse 컴포넌트의 마크업과 스타일링

마크업은 하나의 div 엘리먼트를 배치하는 것으로 충분하다.

children 입력으로 들어오는 어떤 콘텐츠 요소든 확장하거나 축소시키게 될 것이다.

Collapse.client.tsx
1"use client";
2
3import { type PropsWithChildren } from "react";
4
5import clsx from "clsx";
6
7export interface CollapseProps extends PropsWithChildren {
8  className?: string;
9}
10
11export default function Collapse({
12  className,
13  children,
14}: CollapseProps) {
15
16  return (
17      <div className={clsx("broccoli-ui-collapse", className)}>
18        {children}
19      </div>
20  );
21}

overflow: hidden 속성 처리가 적용되는 것은 내부 콘텐츠 요소가 Collpase 컨테이너 영역을 뚫고 나오는 것을 방지하기 위함이다.

트랜지션 효과를 부여하기 위해 transition-property: height 속성과 transition-timing-function: ease-in-out 속성을 적용한다.

보통은 transition-duration 속성도 같이 지정을 하지만 나름의 이유가 있어서 스타일 시트에서 지정하지 않았다.

다음 내용으로 넘어가자.

style.css
1.broccoli-ui-collapse {
2  overflow: hidden;
3  transition-property: height;
4  transition-timing-function: ease-in-out;
5}

CSSTransition 컴포넌트 적용하기

Collapse 컴포넌트의 동작을 구현하기 위해 CSSTransition 컴포넌트를 활용하자.

먼저 타입 인터페이스를 작성한다. 이때 duration 속성은 트랜지션 효과의 지속 시간을 지정하는 속성이다.

스타일시트에 작성하지 않고 프로퍼티를 통해 전달받도록 지정한 부분을 기억해두자.

Collapse.client.tsx
1import { type PropsWithChildren } from "react";
2
3export interface CollapseProps extends PropsWithChildren {
4  className?: string; // Collapse 컨테이너에 스타일 오버라이드가 필요한 경우
5  isOpen?: boolean; // 부모 컴포넌트에서 제어하는 Collapse 여닫음 상태값
6  appear?: boolean; // Collpase가 처음에 열린 상태로 표시될 때 열리는 트랜지션 효과를 부여할지 여부
7  duration?: number; // 트랜지션 효과의 지속 시간
8  onEnter?: () => void; // Collapse가 열리기 시작할 때 호출되는 콜백 함수
9  onEntered?: () => void; // Collpse가 완전히 열린 후 호출되는 콜백 함수
10  onExit?: () => void; // Collapse가 닫히기 시작할 때 호출되는 콜백 함수
11  onExited?: () => void; // Collapse가 완전히 닫힌 후 호출되는 콜백 함수
12}

타입 인터페이스가 결정되었으면 함수 컴포넌트에 적용한다.

Collapse는 대부분의 경우 닫힌 상태에서 시작하는 것이 주요 유즈케이스이다.

isOpen 상태의 기본값을 false로 지정해주도록 하자.

CSSTransition 컴포넌트를 도입하여 트랜지션 효과 제어기로 활용하도록 한다.

필요한 프로퍼티 입력은 공식 문서 가이드를 따르자.

간단 설명을 소스 주석에 남겼다.

Collapse.client.tsx
1export default function Collapse({
2  className,
3  isOpen = false,
4  appear = false,
5  duration = 250, // 250ms
6  children,
7  onEnter: onEnterProp,
8  onEntered: onEnteredProp,
9  onExit: onExitProp,
10  onExited: onExitedProp,
11}: CollapseProps) {
12  const nodeRef = useRef<HTMLDivElement>(null); // Transtion 컴포넌트가 관리할 HTMLElement의 참조
13
14  return (
15    <CSSTransition
16      nodeRef={nodeRef}
17      timeout={duration} // 트랜지션 효과의 지속시간을 입력합니다. 대개 transition-duration 속성과 동일한 값을 지정합니다.
18      in={isOpen} // 트랜지션 효과가 트리거되는 플래그 역할을 합니다. false -> true로 향할때 enter, true -> false로 향할때 exit 트랜지션 효과가 발생합니다.
19      appear={appear}
20      onEnter={onEnter}
21      onEntering={onEntering}
22      onEntered={onEntered}
23      onExit={onExit}
24      onExiting={onExiting}
25      onExited={onExited}
26    >
27      <div
28        className={clsx("broccoli-ui-collapse", className)}
29        ref={nodeRef}
30      >
31        {children}
32      </div>
33    </CSSTransition>
34  );
35}

트랜지션 지속시간을 스타일시트에 지정하지 않고 프로퍼티로 처리한 것은 17번 라인이 주요 이유이다.

Collpase를 사용하는 개발자가 지속 시간을 바꾸고 싶다고 해보자.

지속시간을 스타일시트에 부여했다면 프로퍼티 값도 변경하고 스타일시트에 입력된 값도 변경해주어야 한다.

하지만 프로퍼티만으로 관리한다면 한 부분만 수정하므로 유지관리가 수월해진다.

다음 단락에서 구현을 이어나가자.

Height 상태값에 따른 트랜지션 효과 적용

CSSTransition 컴포넌트까지 적용했으면 Height 상태값이 제어될 수 있도록 만들 차례이다.

Collapse 컴포넌트의 여닫음은 부모컴포넌트에서 제어하고 있으니 Height값을 관리하는 상태를 선언한다.

이 값을 div 엘리먼트의 height 속성에 연결하고 트랜지션 지속시간도 같이 처리하자.

  • isOpentrue 이면 height:auto 이다. 콘텐츠가 차지하는 레이아웃 높이를 따르게 된다.
  • isOpenfalse 이면 height:0px 이다. 닫혀있는 상태를 만들기 위함이다.
Collapse.client.tsx
1export default function Collapse({...}: CollapseProps) {
2  const nodeRef = useRef<HTMLDivElement>(null);
3  const [height, setHeight] = useState(isOpen ? 'auto' : '0px');
4
5  return (
6    <CSSTransition {...transitionProps}>
7      <div
8        style={{ height, transitionDuration: `${duration}ms` }}
9        className={clsx('broccoli-ui-collapse', className)}
10        ref={nodeRef}
11      >
12        {children}
13      </div>
14    </CSSTransition>
15  );
16}

다음으로 onEnter, onEntering, onEntered, onExit, onExiting, onExited 콜백 함수를 활용한다.

부모컴포넌트에서 isOpen 값을 토글하면 CSSTransition 컴포넌트의 in 플래그가 트리거되어 콜백함수가 호출되기 시작한다.

그러므로 각 단계에 맞는 콜백 함수를 구현하여 Height 상태값을 업데이트 할 것이다.

  • Collapse가 열리는 단계는 onEnter, onEntering, onEntered 콜백 함수가 호출된다.
  • Collapse가 닫히는 단계는 onExit, onExiting, onExited 콜백 함수가 호출된다.

여기서 알아가야할 중요한 점은 트랜지션 효과가 부여되기 위해서는 수치값에 기반한 스타일 속성값이 필요하다는 부분이다.

예를 들어, 열리는 단계 onEnter, onEntering, onEntered를 살펴보자.

  • 열리기 시작하는 시점에서는 height: 0px로 설정한다.
  • 열리는 과정의 시점에서는 height: auto가 아닌 콘텐츠 영역의 높이를 getHeight 함수를 통해 수치값으로 설정한다.
  • 열리고 난 이후의 시점에서는 다시 height: auto로 설정한다. 이 이유는 Collapse가 열리고 난 이후 높이값이 특정 px로 고정되어 있는 상태로 머물러 있게 되면 콘텐츠의 내용이 변화하여 콘텐츠 영역의 높이가 변경되더라도 Collapse 컨테이너의 높이는 콘텐츠의 높이를 따라가지 못하는 상태가 되기 때문이다.
Collapse.client.tsx
1export default function Collapse({...}: CollapseProps) {
2  const nodeRef = useRef<HTMLDivElement>(null);
3  const [height, setHeight] = useState(isOpen ? 'auto' : '0px');
4
5  const getHeight = () => {
6    return nodeRef.current ? `${nodeRef.current.scrollHeight}px` : '0px';
7  };
8
9  const onEnter = () => {
10    onEnterProp?.();
11    setHeight('0px');
12  };
13
14  const onEntering = () => {
15    setHeight(getHeight()); // 트랜지션 효과가 발생하기 위해 수치값으로 설정합니다.
16  };
17
18  const onEntered = () => {
19    onEnteredProp?.();
20    setHeight('auto'); // 열리고 난 이후에는 콘텐츠 영역의 높이를 따라가도록 합니다.
21  };
22
23  const onExit = () => {
24    onExitProp?.();
25    setHeight(getHeight());
26  };
27
28  const onExiting = () => {
29    setHeight('0px');
30  };
31
32  const onExited = () => {
33    onExitedProp?.();
34  };
35
36  return (
37    <CSSTransition
38      nodeRef={nodeRef}
39      timeout={duration}
40      in={isOpen}
41      appear={appear}
42      onEnter={onEnter}
43      onEntering={onEntering}
44      onEntered={onEntered}
45      onExit={onExit}
46      onExiting={onExiting}
47      onExited={onExited}
48    >
49      <div
50        style={{ height, transitionDuration: `${duration}ms` }}
51        className={clsx('broccoli-ui-collapse', className)}
52        ref={nodeRef}
53      >
54        {children}
55      </div>
56    </CSSTransition>
57  );
58}

여기까지가 Collapse 컴포넌트의 구현이다.

어떻게 사용하죠?

이제 부모 컴포넌트의 관점에서 Collapse 컴포넌트를 사용하는 방법을 알아보자.

기본적인 사용법은 isOpen 상태값을 토글하는 것이다.

Page.tsx
1export default function Page() {
2  const [isOpen, setIsOpen] = useState(false);
3
4  const toggle = () => {
5    setIsOpen(!isOpen);
6  };
7
8  return <Collapse isOpen={isOpen}>{children}</Collapse>;
9}

Collapse


처음부터 열려있길 원하며 열리는 효과부터 시작하고 싶다면 isOpenappear 프로퍼티를 true로 설정하면 된다.

Page.tsx
1export default function Page() {
2  return (
3    <Collapse isOpen appear>
4      {children}
5    </Collapse>
6  );
7}

Collapse


트랜지션 지속시간을 변경하고 싶다면 duration 프로퍼티를 입력하면 된다.

효과를 원치 않는다면 duration 프로퍼티를 0으로 설정하면 된다.

Page.tsx
1export default function Page() {
2  return <Collapse duration={1000}>{children}</Collapse>;
3}

Collapse


Collapse가 열리고 닫히는 과정의 특정 시점에서 무언가 동작시키고 싶다면 onEnter, onEntered, onExit, onExited 콜백 함수를 활용하면 된다.

Page.tsx
1export default function Page() {
2  return (
3    <Collapse
4      onEnter={() => alert('onEnter')}
5      onEntered={() => alert('onEntered')}
6      onExit={() => alert('onExit')}
7      onExited={() => alert('onExited')}
8    >
9      {children}
10    </Collapse>
11  );
12}

Collapse