모달 창 사용 예제
confirmDialog/alertDialog가 예/아니오 선택을 Promise로 돌려주는 것과 달리, Modal은 폼 등 임의의
내용을 담을 수 있는 선언형 컴포넌트다 — 열림 상태(open/handleClose)를 부모가 직접 들고 제어한다.
Modal 공용 컴포넌트
Section titled “Modal 공용 컴포넌트”- 기본 푸터는 확인/취소 두 버튼이다.
footer를 직접 넘기면 그 내용을 그대로 쓰고,handleConfirm만 넘기면 기본 확인/취소 버튼이 생성된다.handleExtra를 함께 넘기면 취소 버튼 왼쪽에 세 번째 버튼(순서: 세 번째 → 취소 → 확인)이 추가된다.handleCancel을 넘기지 않으면 취소 버튼은handleClose를 그대로 호출한다.components/molecules/modal/Modal.tsx import {CloseButton, Dialog, Portal} from '@chakra-ui/react'import {Button} from '../../atoms'export const Modal = ({open,handleClose,title,footer,confirmText,cancelText,confirmColorPalette,handleConfirm,handleCancel,extraText,extraColorPalette,handleExtra,size,width,height,fullScreen = false,children,}: ModalProps) => {const handleCancelClick = () => {if (handleCancel) {handleCancel()return}handleClose()}const resolvedFooter =footer ??(handleConfirm && (<>{handleExtra && (<Buttonvariant="ghost"colorPalette={extraColorPalette ?? 'gray'}handleClick={handleExtra}>{extraText ?? '추가'}</Button>)}<Button variant="outline" colorPalette="gray" handleClick={handleCancelClick}>{cancelText ?? '취소'}</Button><Button colorPalette={confirmColorPalette ?? 'brand'} handleClick={handleConfirm}>{confirmText ?? '확인'}</Button></>))return (<Dialog.Rootopen={open}onOpenChange={handleOpenChange}size={size ?? 'md'}placement="center"scrollBehavior="inside"><Portal><Dialog.Backdrop /><Dialog.Positioner><Dialog.Content width={resolvedWidth} maxW={resolvedWidth} height={resolvedHeight} maxH={resolvedHeight}>{title && (<Dialog.Header><Dialog.Title>{title}</Dialog.Title></Dialog.Header>)}<Dialog.Body>{children}</Dialog.Body>{resolvedFooter && <Dialog.Footer>{resolvedFooter}</Dialog.Footer>}<Dialog.CloseTrigger asChild><CloseButton size="sm" /></Dialog.CloseTrigger></Dialog.Content></Dialog.Positioner></Portal></Dialog.Root>)} handleConfirm/handleExtra가Promise를 반환하면 완료까지 해당 버튼에 스피너가 뜬다(Button(@/components)의handleClick동작을 그대로 따른다). 오른쪽 위 닫기 버튼(Dialog.CloseTrigger)은footer구성과 무관하게 항상 표시된다.- 크기는
fullScreen>width/height>size순으로 우선한다 —fullScreen이면 나머지는 모두 무시하고 뷰포트에서 상하좌우 20px씩 뺀 크기로 띄운다.
사용 예: react-hook-form + zod 등록/수정 모달
Section titled “사용 예: react-hook-form + zod 등록/수정 모달”domainprop 유무로 등록/수정 모드를 가른다 — 있으면PUT(수정), 없으면POST(등록). 아래는 모달과 관련된 부분만 옮긴 것이다. 같은 파일 위쪽에 정의된 zod 스키마(domainFormSchema), 초기값 (DOMAIN_FORM_INITIAL), 변환 함수(toDomainFormValues/toDomainRequestPayload)는 폼 작성 가이드에서 설명한다.pages/word/domain/components/DomainFormModal.tsx export const DomainFormModal = ({open, handleClose, domain}: DomainFormModalProps) => {const {saveDomain} = useDomainSaveMutation()const {updateDomain} = useDomainUpdateMutation()const {register, control, handleSubmit, reset, formState: {errors}} = useForm<DomainFormValues>({resolver: zodResolver(domainFormSchema),defaultValues: DOMAIN_FORM_INITIAL,})// 모달이 열릴 때마다 그 시점의 domain 기준으로 폼을 다시 채운다.useEffect(() => {if (open) {reset(domain ? toDomainFormValues(domain) : DOMAIN_FORM_INITIAL)}}, [open, domain, reset])const handleConfirm = handleSubmit(async (values) => {const payload = toDomainRequestPayload(values)try {if (domain && isNotNull(domain.id)) {await updateDomain({id: domain.id, payload})} else {await saveDomain(payload)}handleClose()} catch {// 실패 토스트는 useDomainSaveMutation/useDomainUpdateMutation의 onError가 이미 띄운다.}})return (<Modalopen={open}handleClose={handleClose}title={domain ? '도메인 수정' : '도메인 등록'}confirmText={domain ? '수정' : '등록'}handleConfirm={handleConfirm}><FormField<DomainFormValues>name="name"label="이름"register={register}error={errors.name}required/>{/* 나머지 폼 필드 */}</Modal>)}handleConfirm은 react-hook-form의handleSubmit으로 감싼다 — 검증에 실패하면onSubmit을 호출하지 않고 각 필드의 에러만 채우므로 모달은 열린 채로 남는다. 요청이 실패해도(에러 토스트는 뮤테이션이 띄운다) 모달을 열어둔 채 사용자가 입력을 고쳐 다시 시도할 수 있게 한다.- 성공 시에만
handleClose()를 호출해 모달을 닫는다. - 부모 페이지는 페이지 훅(
useDomain)에서 열림 상태(isFormModalOpen)와 수정 대상(editingDomain)을useState로 들고 있다가 모달에 넘긴다. 모달을 닫아도 폼을 따로 비울 필요는 없다 — 다음에 열릴 때 위useEffect가 다시 채운다.