React + Styled-components로 구현해본 아코디언(Accordion)
간단하게 아코디언(Accordion)을 구현해 보려고 한다.
사실 Bootstrap, Material-ui 등 이미 많이 구현되어 있어서 직접 구현할 일은 많지 않겠지만, 자체 디자인 시스템을 만들기 시작한 지 얼마 안 되었다면 가끔은 직접 구현할 일도 있다. 사실 내 이야기이다... 뭐 여하튼... 어려운 것은 아니니깐 살짝만 구현해 보려고 한다.
Accordion이 뭔데?
Accordion은 접었다 펼수 있는 카드 컴포넌트라고 생각하면 쉽다. 예를 들어 실제 우리 홈페이지에서 내가 구현한 컴포넌트를 본다면 아래와 같은 모습이다.
결제 페이지인만큼 애니메이션은 과하지 않은 선에서 자연스럽게 적용해봤다. Typescript와 styled-components를 이용해서 만들어 봤는데, 사실 성능을 생각한다면 바닐라 자바스크립트로 짜는 것이 가장 좋다(예제도 엄청 많고 좋은 오픈 소스도 많다..!). 이미 많은 예제가 있으니 그것들을 참고해보는 건 추천한다. 이 말을 앞에서 먼저 하는 이유는 이것이 정답이 아니라는 말이다. 나 또한 계속 더 좋은 코드와 방법을 찾을 것이다.
예제 준비
위와 같은 상황이다. 나는 Lorem 글을 아코디언으로 가릴 것이다.
import React from "react";
function Accordion() {
return <>아코디언</>;
}
export default React.memo(Accordion);
일단 간단하게 아코디언 컴포넌트의 기본 뼈대를 만들고 생각해보자.
시작전 고려 사항
1. 용도가 무엇인가? 어디서 사용되는가?
2. 재사용성을 어느 정도까지 고려해야 하는가?
3. UI를 어느정도어느 정도 제어할 것인가(=== 사용자에게 어느 정도의 자유를 줄 것인가)
4. 성능에 따른 최적화를 고려하자(이건 답정 바닐라... 만약 바닐라가 아니라면 그래도 조금이라도 최적화할 수 있는 방법을 찾자)
5. 좋은 UX를 줄 수 있는 인터렉션은 무엇인가?
6. 내가 참고할만한 좋은 오픈 소스가 존재하는가?
7. 그냥 이미 있는 좋은 라이브러리를 랩핑해서 사용하는 건 별로인가?
8. ie ...
이 글에서는 6, 7번은 고려하지 않았다. 저거 고려하면 여기서 이 글 쓰면 안 된다 :)
UI 구상하기
위에 있는 고려사항에 대한 검토가 끝났다면 UI를 구상해 본다. 회사였다면 디자이너가 있기 때문에 규정대로 하면 될 것이다.
디자이너가 없다면 많은 ui 라이브러리와 실제 웹사이트에서 구현된 선행 예제를 보면 된다.
나는 예제이기 때문에 간단하게만 만들어 봤다.
쉬어 가기
나는 DOM에 접근하기 위해 React에서 제공하는 useRef hook을 사용할 것이다. 이 훅에 익숙지 않다면 이 자료를 보는 것을 추천한다. 이 컴포넌트를 만들때 가장 고민이 되는 부분은 바로 "컨텐츠의 크기를 어떻게 확인할 것인가?"이다. 이미 useRef를 통해 DOM에 접근한다고 말했으니 답을 말한 거나 다름이 없긴 하다.
앞으로의 설명을 편하게 하기위해 [타이틀 - 열기/닫기]가 있는 부분을 헤더 컴포넌트, [컨텐츠]가 있는 부분을 컨텐츠 컴포넌트라고 부르겠다. 기본적인 레이아웃은 flex를 통해 제어할 것이다.
부모 컴포넌트 만들기
function Accordion() {
return (
<Container>
</Container>
);
}
const Container = styled.div`
display: flex;
position: relative;
flex-direction: column;
`;
먼저 큰 컨테이너로 하나 감싸 준다. 가장 밖에 있는 컴포넌트의 역할은 가장 밖에서 틀을 제공해 줌과 동시에 [열기/닫기] 버튼을 고정시킬 영역을 제한하기 위해서 이다. [열기/닫기] 버튼은 항상 이 컴포넌트의 크기가 어떻게 변하건(flex이므로 기본적으로 유연하게 늘어날 것이다.) 오른쪽에 위치하고 있어야 한다. 그래서 아코디언의 가장 바깥쪽에서 position을 relative로 주고 [열기/닫기] 버튼을 position: absolute로 줄 것이다.
type Props = {
title?: string | React.ReactNode;
}
function Accordion(props: Props) {
return (
<Container>
<Header>
{props.title}
<Button>열기</Button>
</Header>
</Container>
);
}
const Container = styled.div`
display: flex;
position: relative;
flex-direction: column;
justify-content: center;
border-radius: 4px;
border: 1px solid silver;
`;
const Header = styled.div`
display: flex;
align-items: center;
height: 32px;
margin: 0 32px 0 8px;
`;
const Button = styled.div`
top: 8px;
right: 8px;
font-size: 14px;
position: absolute;
`;
말하지 않은 부분(font-size 등)이 추가되어있다면 그냥 보기 좋게 하려고 하는 거니깐, 본인 디자인에 맞게 수정하면 된다.
여기까지 잘 따라왔다면 이제 컨텐츠 영역을 만들 차례이다. 위에서 언급한 대로 ref를 이용한다.
type Props = {
title?: string | React.ReactNode;
contents?: string | React.ReactNode;
}
function Accordion(props: Props) {
const parentRef = React.useRef<HTMLDivElement>(null);
const childRef = React.useRef<HTMLDivElement>(null);
return (
<Container>
<Header>
{props.title}
<Button>열기</Button>
</Header>
<ContentsWrapper ref={parentRef}>
<Contents ref={childRef}>{props.contents}</Contents>
</ContentsWrapper>
</Container>
);
}
...
const ContentsWrapper = styled.div`
height: 0;
width: 100%;
overflow: hidden;
transition: height 0.35s ease;
`;
const Contents = styled.div``;
높이를 0으로 준 이유는 기본적으로 컨텐츠 영역을 숨기게 하기 위해서이다. 이제 DOM에 접근하여 contents 영역을 계산한 다음 직접 그 계산된 컨텐츠 높이만큼을 줄 것이다. 이 부분은 열기를 눌렀을때 작동하면 되므로 버튼 부분에 핸들러를 하나 만들어 준다.
const [isCollapse, setIsCollapse] = React.useState(false);
const handleButtonClick = React.useCallback(
event => {
event.stopPropagation();
if (parentRef.current === null || childRef.current === null) {
return;
}
if (parentRef.current.clientHeight > 0) {
parentRef.current.style.height = "0";
} else {
parentRef.current.style.height = `${childRef.current.clientHeight}px`;
}
setIsCollapse(!isCollapse);
},
[isCollapse]
);
위 코드는 돔에 접근하여 콘텐츠의 높이를 읽어와서 넣어주고 있는 것이다. 여기까지 했으면 이제 [열기] 상태일때는 해당 컨텐츠 높이만큼 높이가 더 늘어날 것이며, 아닐때는 0으로 바뀔 것이다. 이걸 가지고 버튼의 값도 변경할 수 있게 되었다.
const parentRefHeight = parentRef.current?.style.height ?? '0px';
const buttonText = parentRefHeight === '0px' ? '열기' : '닫기';
그리고 한번 테스트를 해보자.
왜 그럴까. 이건 clientHeight의 스펙을 제대로 고려를 안했기 때문에 이렇다. 스펙부터 다시보자.
border의 크기를 컨텐츠의 높이라고 생각해서 디자인을 하는 경우는 많지 않기 때문에 border의 크기 무시까지는 괜찮았다. (만약 이거까지 포함하고 싶다면 offsetHeight를 쓰면 된다.) 하지만 문제는 clientHeight는 margin의 크기를 가지고 있지 않다. 실제로 크롬으로 엘레멘트를 확인해도 마진이 존재함을 알 수 있다.
이제 문제는 알았으니 해결은 쉽다. 간단하게 padding만 <Contents /> 부분에 추가해 주면 된다.
const Contents = styled.div`
padding: 4px 8px;
`;
마지막으로 다시 테스트 해보자.
가끔 프론트 개발자로써 직접 컴포넌트를 하나하나 만들고 싶을 때 사이드 프로젝트겸 해보면 재밌다. 일상이 지루하다면 하나하나 직접 만들어보는 것도 재밌을듯하다. 회사에서 디자인시스템을 구축한다면 직접 참여하는 것도 당연 좋다^.^
실제로 만들고 있는 것들을 가끔 이렇게 단순화해서 몇 개씩 포스팅할 예정이다 :)