React와 TypeScript, Styled-Components를 활용한 아코디언 메뉴 컴포넌트 만들기

0

아코디언 메뉴는 콘텐츠를 확장하거나 축소할 수 있게 해주는 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를 사용하여 효율적이고 확장 가능한 아코디언 컴포넌트를 만드는 데 도움이 되길 바랍니다. 더 나아가, 이러한 컴포넌트를 활용하여 사용자 경험을 한층 더 향상시킬 수 있기를 기대합니다.

Leave a Reply