Modal의 타입/버튼 처리와 BottomSheet의 목록 렌더링, props, 조건부 렌더링, 재사용 설계까지 한 페이지에서 정리합니다.
const MODAL_TYPES = {
alert: styles.alert,
info: styles.info,
confirm: styles.confirm,
error: styles.error,
}
type="error"가 들어오면 styles.error가 선택됩니다. 타입에 따라 색상이나 아이콘 스타일을 다르게 만들기 위한 구조입니다.
const MODAL_TITLES = {
alert: '알림',
info: '안내',
confirm: '확인',
error: '오류',
}
사용자가 title을 직접 전달하지 않아도 type에 맞는 기본 제목이 출력됩니다.
function ModalIcon({ type }) {
if (type === 'confirm') {
return <svg>체크 아이콘</svg>
}
if (type === 'error') {
return <svg>X 아이콘</svg>
}
if (type === 'info') {
return <svg>정보 아이콘</svg>
}
return <svg>알림 아이콘</svg>
}
조건에 따라 다른 SVG를 반환합니다. 마지막 return은 기본값이며 사실상 alert용 기본 아이콘입니다.
function getButtonVariant(button, index, total) {
if (button.variant) return button.variant
if (total === 1) return 'secondary'
return index === 0 ? 'outlineLight' : 'secondary'
}
우선순위는 다음과 같습니다.
export default function Modal({
type = 'alert',
title,
mainText,
subText,
children,
buttons = [{ label: '버튼명' }],
buttonRatio = '50-50',
icon,
className,
iconClassName,
contentClassName,
buttonGroupClassName,
role = 'dialog',
...props
})
| prop | 역할 |
|---|---|
| type | alert / info / confirm / error |
| title | 모달 제목을 직접 지정 |
| mainText | 주요 설명 |
| subText | 보조 설명 |
| children | 정해진 구조 외의 추가 JSX |
| buttons | 하단 버튼 설정 배열 |
| buttonRatio | 버튼 2개일 때 폭 비율 |
| icon | 기본 ModalIcon 대신 사용자 지정 아이콘 |
| ...props | 나머지 속성을 최상위 section에 전달 |
const modalType =
MODAL_TYPES[type] ?? MODAL_TYPES.alert
존재하지 않는 타입이 들어오면 MODAL_TYPES[type]이 undefined가 되므로 기본 alert 스타일을 사용합니다.
const modalTitle =
title ?? MODAL_TITLES[type] ?? MODAL_TITLES.alert
{icon ?? <ModalIcon type={type} />}
사용자 지정 icon이 있으면 그것을 사용하고, 없으면 기본 아이콘을 사용합니다.
const buttonItems =
buttons.filter(Boolean).slice(0, 2)
[
{ label: '취소' },
null,
false,
{ label: '확인' }
]
위 배열에서 null, false 같은 falsy 값을 제거합니다.
앞에서부터 최대 2개까지만 사용합니다. 따라서 Modal의 디자인 규칙인 버튼 최대 2개를 코드에서 강제합니다.
{modalTitle && <strong>{modalTitle}</strong>}
{mainText && <p>{mainText}</p>}
{subText && <p>{subText}</p>}
{children}
값 && JSX는 값이 존재할 때만 해당 JSX를 렌더링하는 React의 대표적인 조건부 렌더링 패턴입니다.
<Modal
type="confirm"
mainText="정말 삭제하시겠습니까?"
>
<div>삭제 후 복구할 수 없습니다.</div>
</Modal>
children 덕분에 Modal 내부에 정해진 props 외의 자유로운 JSX를 추가할 수 있습니다.
<ButtonGroup
ratio={
buttonItems.length === 1
? 'equal'
: buttonRatio
}
>
{buttonItems.map((button, index) => (
<Button
key={`${button.label}-${index}`}
size="xl"
variant={getButtonVariant(
button,
index,
buttonItems.length
)}
onClick={button.onClick}
disabled={button.disabled}
type={button.type ?? 'button'}
>
{button.label}
</Button>
))}
</ButtonGroup>
버튼 설정 객체 배열을 map()으로 실제 <Button> 컴포넌트들로 변환합니다.
buttons={[
{
label: '취소',
onClick: handleCancel
},
{
label: '확인',
onClick: handleConfirm
}
]}
<Modal
type="confirm"
mainText="정말 삭제하시겠습니까?"
subText="삭제된 데이터는 복구할 수 없습니다."
buttons={[
{
label: '취소',
onClick: handleCancel,
},
{
label: '삭제',
onClick: handleDelete,
},
]}
/>
작은 SVG를 별도 함수 컴포넌트로 분리해서 메인 JSX가 지나치게 복잡해지지 않게 합니다.
function CheckIcon() {
return <svg>...</svg>
}
function CloseIcon() {
return <svg>...</svg>
}
export default function BottomSheet({
title = '주요문구',
subText = '부가설명',
items = [],
buttonLabel = '버튼명',
onButtonClick,
onClose,
className,
contentClassName,
buttonClassName,
...props
})
| prop | 역할 |
|---|---|
| title | 제목 |
| subText | 보조 설명 |
| items | 목록 데이터 배열 |
| buttonLabel | 하단 버튼 문구 |
| onButtonClick | 하단 버튼 클릭 함수 |
| onClose | 닫기 버튼 클릭 함수 |
| ...props | 최상위 section에 추가 속성 전달 |
<section
role="dialog"
aria-modal="true"
>
현재 영역이 대화상자 형태의 UI라는 것을 보조기기에 알려줍니다.
<button
type="button"
aria-label="Close"
onClick={onClose}
>
<CloseIcon />
</button>
아이콘만 있는 버튼은 의미가 시각적으로만 전달되기 쉬우므로 aria-label을 넣습니다. 한글 서비스라면 aria-label="닫기"처럼 작성할 수도 있습니다.
{items.length > 0 && (
<ul>
{items.map((item, index) => (
<li key={`${item.label}-${index}`}>
...
</li>
))}
</ul>
)}
배열에 항목이 하나 이상 있을 때만 목록 전체를 렌더링하고, 각 객체를 <li>로 변환합니다.
items={[
{
label: '보장내용 확인',
rightText: '필수'
},
{
label: '약관 동의',
rightText: '선택'
}
]}
<span className={styles.check}>
{item.icon ?? <CheckIcon />}
</span>
개별 item에 사용자 지정 아이콘이 있으면 그것을 사용하고, 없으면 기본 체크 아이콘을 사용합니다.
{
label: '완료',
icon: <StarIcon />
}
{item.rightText && (
<span className={styles.itemRight}>
{item.rightText}
</span>
)}
rightText가 있을 때만 우측 부가 정보를 출력합니다.
<Button
variant="gradation"
size="xxl"
fullWidth
onClick={onButtonClick}
>
{buttonLabel}
</Button>
기존 Button 컴포넌트의 variant, size, fullWidth 기능을 그대로 재사용합니다.
<BottomSheet
title="보험 가입 안내"
subText="가입 전 아래 내용을 확인해주세요."
items={[
{
label: '보장 내용을 확인했습니다.',
rightText: '필수',
},
{
label: '약관을 확인했습니다.',
rightText: '필수',
},
{
label: '마케팅 정보 수신',
rightText: '선택',
},
]}
buttonLabel="확인"
onClose={handleClose}
onButtonClick={handleConfirm}
/>
| Modal | BottomSheet |
|---|---|
| 중앙 팝업 형태 | 화면 하단에서 올라오는 형태 |
| alert / info / confirm / error 타입 | 하나의 기본 형태 |
| 버튼 1~2개 | 주요 버튼 1개 |
| ButtonGroup 사용 | Button 단독 사용 |
| children으로 자유 콘텐츠 가능 | items 배열 중심 |
| 확인 / 경고 / 오류에 적합 | 상세 안내 / 선택 정보에 적합 |
{value && <Component />}
{icon ?? <DefaultIcon />}
{items.map(item => (
<Item />
))}
<section {...props}>
즉 Modal과 BottomSheet는 모든 기능을 처음부터 직접 만드는 것이 아니라, 이미 만들어 둔 Button, ButtonGroup, Icon 같은 작은 컴포넌트를 조합해 만든 조합형 컴포넌트(Composite Component)로 이해하면 좋습니다.