🔴 기존 컴포넌트의 문제점
YEONSUI 버전 1에서 만든 Modal 컴포넌트는 Modal
이라는 컴포넌트 하나 안에서 props만으로 기능 유무를 결정한다.
return (
<Modal
title="Are you sure delete this task?"
icon="warning"
onClose={() => {}}
isOpen
labelClose="No"
labelSave="Yes"
onSave={() => {}}
>
Some contents...
</Modal>
)
이렇게 많은 props를 받는 것은 컴포넌트 내부 로직이 추가될 때마다 점점 복잡해지기 때문에 확장성이 떨어지고, 모달의 역할을 정확히 알지 못해 가독성이 떨어진다.
🟢 해결책: 합성 컴포넌트 패턴
이러한 문제를 해결하기 위해 도입한 것이 합성 컴포넌트(Compound Component)이다. 이 패턴은 작은 단위로 분리한 컴포넌트들의 조합으로 또 다른 컴포넌트를 만들 수 있게 한다. 이를 통해 역할을 분담시키고 추상화 레벨을 낮춰 확장성과 가독성을 높인다.
지금부터 위와 같은 컴포넌트를 합성 컴포넌트 패턴을 도입해 만들어보자.
🔵 합성 컴포넌트 만들기
// Modal.tsx
export const ModalWrapper = () => {}
// Layout
export const ModalHeader = () => {}
export const ModalContent = () => {}
export const ModalFooter = () => {}
// Information
export const ModalTitle = () => {}
export const ModalButton = () => {}
ModalWrapper
: Modal 요소 전체를 감싸는 컴포넌트ModalHeader
: 상단의 타이틀과 Close 버튼을 감싸는 컴포넌트ModalContent
: 콘텐츠 요소를 감싸는 컴포넌트ModalFooter
: 하단의 동작 버튼을 감싸는 컴포넌트ModalTitle
: 타이틀을 나타내는 컴포넌트ModalButton
: 단일 동작 버튼을 나타내는 컴포넌트
이를 통해 사용자가 Modal 안에 넣고 싶은 요소를 골라서 사용할 수 있다.
// index.tsx
import { ModalButton, ModalContent, ModalTitle, ModalWrapper, ModalFooter, ModalHeader } from './Modal'
export const Modal = Object.assign(ModalWrapper, {
Header: ModalHeader,
Content: ModalContent,
Footer: ModalFooter,
Title: ModalTitle,
Button: ModalButton,
})
ModalWrapper
를 메인 컴포넌트로, 그 외에는 서브 컴포넌트로 묶어서 export하였다. 이렇게 한 이유는 하위 컴포넌트가 Modal과 연관된 작업을 한다는 의미를 주기 위해서이다.
return (
<Modal>
<Modal.Header>
<Modal.Title></Modal.Title>
</Modal.Header>
<Modal.Content></Modal.Content>
<Modal.Footer>
<Modal.Button></Modal.Button>
<Modal.Button></Modal.Button>
</Modal.Footer>
</Modal>
)
Modal
(= ModalWrapper
) 자식 요소로 하위 컴포넌트를 퍼즐처럼 맞춰서 사용할 수 있다.
이제 각 컴포넌트에 타입과 요소를 정해주면 끝난다.
export const ModalWrapper = ({ isOpen, onClose, children }: ModalProps) => {
return (
<Popover isOpen={isOpen} onClose={onClose} pos="center">
<div className="ui-modal">{children}</div>
</Popover>
)
}
// Layout
export const ModalHeader = ({ children, hasCloseButton }: ModalHeaderProps) => {
return (
<div className="ui-modal-header">
{children}
{hasCloseButton && (
<Button size="small" styleType="icon" styleVariant="secondary" onClick={hasCloseButton}>
<XIcon />
</Button>
)}
</div>
)
}
export const ModalContent = ({ children }: ModalContentProps) => {
return <div>{children}</div>
}
export const ModalFooter = ({ children }: ModalFooterProps) => {
return <div className="ui-modal-footer">{children}</div>
}
// Information
export const ModalTitle = ({ children, state }: ModalTitleProps) => {
const StateIcon =
state === 'warning' || state === 'error'
? WarningFilledIcon
: state === 'success'
? CheckCircleFilledIcon
: state === 'information'
? InfoFilledIcon
: null
return (
<div className="ui-modal-title">
{StateIcon && <StateIcon className={state} size={24} />}
<h2>{children}</h2>
</div>
)
}
export const ModalButton = ({ type = 'cancel', onClick, children }: ModalButtonProps) => {
const buttonStyleType = type === 'cancel' ? 'outlined' : 'filled'
const buttonStyleVariant = type === 'cancel' ? 'tertiary' : 'primary'
return (
<Button size="large" styleType={buttonStyleType} styleVariant={buttonStyleVariant} onClick={onClick}>
{children}
</Button>
)
}
export const ModalExample = () => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button styleType="filled" onClick={() => setIsOpen((prev) => !prev)}>
Modal Open
</Button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Modal.Header hasCloseButton={() => setIsOpen(false)}>
<Modal.Title state="success">타이틀</Modal.Title>
</Modal.Header>
<Modal.Content>
대화 상자는 사용자에게 작업에 대해 알리고 중요한 정보를 포함하거나 결정이 필요하거나 여러 작업을 포함할 수
있습니다.
</Modal.Content>
<Modal.Footer>
<Modal.Button onClick={() => setIsOpen(false)}>취소</Modal.Button>
<Modal.Button
type="ok"
onClick={() => {
setIsOpen(false)
alert('저장되었습니다.')
}}
>
저장
</Modal.Button>
</Modal.Footer>
</Modal>
</>
)
}
합성 컴포넌트로 Modal 컴포넌트를 완성하였다.
🟢 스토리북 작성하기
이제 끝!이 아니고 스토리북에 Modal 컴포넌트에 대한 정의를 해야한다. 여기서 문제가 발생하였다.
// Modal.stories.tsx
const meta: Meta = {
title: 'Components/Modal',
component: Modal,
}
export default meta
export const ModalTitleExample = () => {
return <Modal.Title>타이틀</Modal.Title>
}
export const ModalButtonExample = () => {
return <Modal.Button>버튼</Modal.Button>
}
하위 컴포넌트에 대한 props를 스토리북의 Controls로 조절하고 싶은데 ModalWrapper
에 대한 Props 값만 나온다. 이런 문제가 발생한 이유는 meta
의 컴포넌트가 Modal
로 설정되어있기 때문이다.
문제를 해결하기 위해 각 하위 컴포넌트에 대한 스토리북 파일을 분리하였다.
// ModalButton.stories.tsx
const meta: Meta = {
title: 'Components/Modal/ModalButton',
component: Modal.Button,
argTypes: {
type: ['cancel', 'ok'],
},
}
export default meta
export const ModalButton: Story<ModalButtonProps> = (args) => {
return <Modal.Button {...args}>버튼</Modal.Button>
}
ModalButton.args = {
type: 'cancel',
onClick: () => alert('clicked!'),
}
meta
의 title, 즉 스토리북 폴더 경로는 Modal
스토리북과 겹치면 안되기 때문에 ModalButton
이라는 하위 폴더를 만들고 동일한 이름으로 컴포넌트를 만들어 export하였다. 이렇게 하면 ModalButton
이라는 컴포넌트가 Modal 경로 안에 바로 들어가게 된다.
이제 하위 컴포넌트에 대한 props 값을 스토리북에서 컨트롤할 수 있게 되었다.