아코디언 메뉴는 콘텐츠를 확장하거나 축소할 수 있게 해주는 UI 패턴으로, 사용자 경험을 향상시키는 데 유용합니다. 이번 기사에서는 React로 사용자 정의 가능한 아코디언 컴포넌트를 만드는 방법을 단계별로 설명합니다. TypeScript를 사용하여 타입 안전성을 확보하고, styled-components를 사용하여 스타일링을 진행합니다.
폴더 구조
코드베이스를 체계적으로 유지하기 위해 다음과 같은 폴더 구조를 사용합니다.
[Accordion]
- [ActionBox]
- ActionBox.tsx
- [Details]
- Details.tsx
- [Summary]
- Summary.tsx
- Accordion.tsx
- Accordion.types.ts
- AccordionContext.ts
타입 정의: `Accordion.types.ts`
우선, 아코디언 컴포넌트를 위한 타입을 정의합니다. 이는 타입 안전성을 확보하고 코드 가독성을 향상시킵니다.
import React from 'react';
export interface AccordionCommonType {
children?: React.ReactNode | React.ReactNode[];
className?: string;
}
export interface AccordionProps extends AccordionCommonType {
defaultExpanded?: boolean;
expanded?: boolean;
onChange?: (expanded: boolean) => void;
disabled?: boolean;
}
export interface AccordionSummaryProps extends AccordionCommonType {
actionIcon?: React.ReactElement;
onClick?: () => void;
hideActionIcon?: boolean;
}
export type AccordionDetailsProps = AccordionCommonType;
export type AccordionDetailRef = {
readjustHeight: () => void;
}
export interface AccordionActionBoxProps extends AccordionCommonType {
isIconButton?: boolean;
render?: (expanded: boolean) => React.ReactElement;
}
컨텍스트: `AccordionContext.ts`
아코디언 컴포넌트의 상태와 동작을 관리하기 위해 컨텍스트를 생성합니다.
import React, { useContext } from 'react';
import { AccordionProps } from './Accordion.types';
interface AccordionContextType extends Pick<AccordionProps, 'expanded' | 'defaultExpanded' | 'disabled'> {
onToggle: () => void;
}
const AccordionContext = React.createContext<AccordionContextType>({
onToggle: () => {},
});
export const useAccordionContext = () => useContext(AccordionContext);
export default AccordionContext;
액션 박스 컴포넌트: `ActionBox.tsx`
이 컴포넌트는 아코디언을 토글하는 액션 아이콘을 렌더링합니다.
import React, { ReactElement, isValidElement } from 'react';
import { AccordionActionBoxProps } from '../Accordion.types';
import { useAccordionContext } from '../AccordionContext';
import IconButton from '@/components/Button/IconButton';
import { Icon } from '@/components/Icon';
const ActionBox = ({
children,
className,
isIconButton = true,
render: renderFn,
}: AccordionActionBoxProps) => {
const { onToggle, expanded = false, disabled } = useAccordionContext();
const renderElement = children && isValidElement(children) ? (
React.cloneElement(children as ReactElement, {
style: {
transform: `rotate(${expanded ? '180' : '0'}deg)`,
transition: 'transform 0.15s',
}
})
) : (
<Icon
name="arrowLeft"
style={{
transform: `rotate(${expanded ? '90' : '-90'}deg)`,
transition: `transform 0.15s`,
}}
/>
);
if (renderFn) return renderFn(expanded);
return isIconButton ? (
<IconButton
size={32}
onClick={(event) => {
event.stopPropagation();
if (disabled) return;
onToggle();
}}
className={className}
color="white"
>
{renderElement}
</IconButton>
) : (
renderElement
);
};
export default ActionBox;
디테일 컴포넌트: `Details.tsx`
이 컴포넌트는 아코디언의 확장 가능한 콘텐츠를 처리합니다.
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import styled from 'styled-components';
import { AccordionDetailsProps, AccordionDetailRef } from '../Accordion.types';
import { useAccordionContext } from '../AccordionContext';
const Details = forwardRef<AccordionDetailRef, AccordionDetailsProps>(
function Details({ children, className }, ref) {
const { expanded } = useAccordionContext();
const innerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<string>('');
useEffect(() => {
if (innerRef.current) {
setHeight(`${innerRef.current.clientHeight}px`);
}
}, [innerRef.current?.clientHeight, children]);
useImperativeHandle(ref, () => ({
readjustHeight: () => {
setHeight('');
setTimeout(() => {
setHeight(`${innerRef.current?.clientHeight}px`);
}, 50);
}
}));
return (
<DetailContainer
height={height}
expanded={expanded}
className={className}
>
<div ref={innerRef}>{children}</div>
</DetailContainer>
);
}
);
export default Details;
type DetailContainerProps = {
expanded?: boolean;
height: string;
}
const DetailContainer = styled('div')<DetailContainerProps>`
width: 100%;
height: ${({ expanded, height }) => (expanded ? height : 0)};
transition: height 0.15s;
transition-delay: 0.05s;
`;
요약 컴포넌트: `Summary.tsx`
이 컴포넌트는 아코디언의 요약 부분과 액션 아이콘을 표시합니다.
import styled from 'styled-components';
import { AccordionSummaryProps } from '../Accordion.types';
import { useAccordionContext } from '../AccordionContext';
import ActionBox from '../ActionBox';
import { Flex } from '@/components/Flex';
const Summary = ({
children,
actionIcon,
onClick,
hideActionIcon,
className,
}: AccordionSummaryProps) => {
const { onToggle, disabled } = useAccordionContext();
return (
<SummaryContainer
className={className}
justify="space-between"
align="center"
onClick={() => {
onClick?.();
if (disabled) return;
onToggle();
}}
boxFill
>
{children}
{!hideActionIcon && (
<SummaryActionIconBox>
{actionIcon ? <ActionBox>{actionIcon}</ActionBox> : <ActionBox />}
</SummaryActionIconBox>
)}
</SummaryContainer>
);
};
export default Summary;
const SummaryContainer = styled(Flex)`
cursor: pointer;
`;
const SummaryActionIconBox = styled('div')`
display: flex;
`;
아코디언 컴포넌트: `Accordion.tsx`
마지막으로, 모든 것을 하나로 통합하여 아코디언 컴포넌트를 완성합니다.
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { AccordionProps } from './Accordion.types';
import AccordionContext from './AccordionContext';
import ActionBox from './ActionBox';
import Details from './Details';
import Summary from './Summary';
const Accordion = ({
children,
defaultExpanded = false,
expanded: expandedProps = defaultExpanded,
className,
onChange,
disabled,
}: AccordionProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(defaultExpanded);
const onToggle = () => {
setExpanded((prev) => !prev);
};
const contextValue = React.useMemo(
() => ({ expanded, defaultExpanded, onToggle, disabled }),
[expanded, defaultExpanded, disabled],
);
useEffect(() => {
setExpanded(expandedProps);
}, [expandedProps]);
useEffect(() => {
onChange?.(expanded);
const containerEl = containerRef.current;
if (!containerEl) return;
const timer = setTimeout(() => {
containerEl.style.overflow = 'initial';
}, 200);
if (!expanded) {
clearTimeout(timer);
containerEl.style.overflow = 'hidden';
}
return () => clearTimeout(timer);
}, [expanded, containerRef.current]);
return (
<AccordionContainer className={className} ref={containerRef}>
<AccordionContext.Provider value={contextValue}>
{children}
</AccordionContext.Provider>
</AccordionContainer>
);
}
Accordion.Summary = Summary;
Accordion.Details = Details;
Accordion.ActionBox = ActionBox;
export default Accordion;
const AccordionContainer = styled('div')`
width: 100%;
overflow: hidden;
align-items: start;
`;
결론
Accordion 컴포넌트는 사용자가 필요에 따라 콘텐츠를 확장하거나 축소할 수 있게 하여 사용자 인터페이스를 향상시키고, 더 나은 사용자 경험을 제공할 수 있습니다. TypeScript를 사용하여 타입 안전성을 확보하고 styled-components를 통해 스타일링을 관리함으로써 유지보수가 용이한 코드를 작성할 수 있습니다.
주요 포인트 요약
- 폴더 구조: 체계적이고 관리하기 쉬운 폴더 구조 설정.
- 타입 정의: TypeScript를 사용하여 각 컴포넌트의 타입을 명확히 정의.
- 컨텍스트 사용: 컨텍스트를 통해 아코디언 상태와 동작 관리.
- 액션 박스 컴포넌트: 토글 기능을 위한 액션 아이콘 구현.
- 디테일 컴포넌트: 확장 가능한 콘텐츠 관리.
- 요약 컴포넌트: 요약 부분과 액션 아이콘 렌더링.
- 아코디언 컴포넌트 통합: 모든 구성 요소를 하나로 통합하여 완전한 아코디언 컴포넌트 생성.
추가 팁
- 스타일 커스터마이징: styled-components를 활용하여 테마나 디자인에 맞게 스타일을 커스터마이징할 수 있습니다.
- 기능 확장: 필요에 따라 컴포넌트에 새로운 기능을 추가할 수 있습니다. 예를 들어, 애니메이션 효과나 다른 사용자 상호작용 패턴을 추가할 수 있습니다.
- 유닛 테스트: 컴포넌트의 각 부분을 유닛 테스트하여 버그를 줄이고 안정성을 높일 수 있습니다.
아코디언 컴포넌트는 다양한 웹 애플리케이션에서 유용하게 사용될 수 있는 UI 요소입니다. 이 가이드가 React와 TypeScript, styled-components를 사용하여 효율적이고 확장 가능한 아코디언 컴포넌트를 만드는 데 도움이 되길 바랍니다. 더 나아가, 이러한 컴포넌트를 활용하여 사용자 경험을 한층 더 향상시킬 수 있기를 기대합니다.