From 41d4eb841a5655b44e351566fd2f031d60854b10 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 20:21:11 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=8E=A8=20Style:=20Button=20border?= =?UTF-8?q?=20=EA=B5=B5=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/button/buttonVariants.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/ui/button/buttonVariants.ts b/src/shared/ui/button/buttonVariants.ts index 987badc..3c2f045 100644 --- a/src/shared/ui/button/buttonVariants.ts +++ b/src/shared/ui/button/buttonVariants.ts @@ -13,11 +13,11 @@ const buttonVariants = cva( primary: 'bg-primary-500 text-white data-[disabled=false]:hover:bg-primary-600 data-[disabled=false]:active:bg-primary-700 disabled:bg-gray-700 aria-disabled:cursor-not-allowed aria-disabled:bg-gray-700', secondary: - 'border-2 border-gray-300 bg-black text-white data-[disabled=false]:hover:bg-gray-900 data-[disabled=false]:active:bg-gray-800 disabled:border-gray-600 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:border-gray-600 aria-disabled:text-gray-500', + 'border border-gray-300 bg-black text-white data-[disabled=false]:hover:bg-gray-900 data-[disabled=false]:active:bg-gray-800 disabled:border-gray-600 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:border-gray-600 aria-disabled:text-gray-500', ghost: - 'border-2 border-transparent bg-transparent text-white data-[disabled=false]:hover:text-primary-500 data-[disabled=false]:active:bg-primary-600 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:text-gray-500', + 'border border-transparent bg-transparent text-white data-[disabled=false]:hover:text-primary-500 data-[disabled=false]:active:bg-primary-600 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:text-gray-500', outline: - 'border-2 border-primary-500 bg-transparent text-primary-500 data-[disabled=false]:hover:bg-primary-300/50 data-[disabled=false]:active:bg-primary-300/60 disabled:border-gray-500 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:border-gray-500 aria-disabled:text-gray-500', + 'border border-primary-500 bg-transparent text-primary-500 data-[disabled=false]:hover:bg-primary-300/50 data-[disabled=false]:active:bg-primary-300/60 disabled:border-gray-500 disabled:text-gray-500 aria-disabled:cursor-not-allowed aria-disabled:border-gray-500 aria-disabled:text-gray-500', }, size: { sm: 'h-7 w-16 body-14', From 0e515b25cc452d653a474e388d42b64fa4630c1c Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 20:25:23 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20ConfirmModal=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/confirm-modal/ConfirmModal.tsx | 116 ++++++++++++++++++ .../ui/confirm-modal/ConfirmModal.types.ts | 56 +++++++++ src/shared/ui/confirm-modal/index.ts | 2 + 3 files changed, 174 insertions(+) create mode 100644 src/shared/ui/confirm-modal/ConfirmModal.tsx create mode 100644 src/shared/ui/confirm-modal/ConfirmModal.types.ts create mode 100644 src/shared/ui/confirm-modal/index.ts diff --git a/src/shared/ui/confirm-modal/ConfirmModal.tsx b/src/shared/ui/confirm-modal/ConfirmModal.tsx new file mode 100644 index 0000000..6808c09 --- /dev/null +++ b/src/shared/ui/confirm-modal/ConfirmModal.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useId } from 'react'; + +import { cn } from '@/shared/styles/utils/cn'; +import { Button } from '@/shared/ui/button'; +import { Surface } from '@/shared/ui/surface'; +import { Title } from '@/shared/ui/title'; + +import type { ConfirmModalProps } from './ConfirmModal.types'; + +/** + * ## ConfirmModal + * + * @description + * 확인/취소 선택이 필요한 짧은 의사결정 UI입니다. 제목, 설명, 확인 버튼, 취소 버튼과 + * 확인 작업의 loading 상태를 제공합니다. + * + * ### 주요 내용 + * + * `Surface`를 기반으로 Focus Trap, Focus Restore, ESC 닫기, 외부 클릭 닫기, Scroll Lock을 + * 재사용합니다. `isLoading` 중에는 확인 버튼에 로딩 표시를 보여주고, 취소 및 dismiss 닫기를 + * 비활성화해 중복 액션을 막습니다. + * + * ### 접근성 + * + * 제목과 설명은 각각 `aria-labelledby`, `aria-describedby`로 dialog에 연결됩니다. + * + * @example + * ```tsx + * + * ``` + */ +export function ConfirmModal({ + cancelButtonProps, + cancelLabel = '취소', + className, + closeOnEscape = true, + closeOnOutsideClick = true, + confirmButtonProps, + confirmLabel = '확인', + description, + isLoading = false, + onCancel, + onConfirm, + title, + ...surfaceProps +}: ConfirmModalProps) { + const generatedId = useId(); + const titleId = `confirm-modal-title-${generatedId}`; + const descriptionId = description ? `confirm-modal-description-${generatedId}` : undefined; + const isCancelDisabled = isLoading; + + return ( + + + + + + + {title} + + {description ? ( +

+ {description} +

+ ) : null} +
+ + + + + + +
+
+
+ ); +} + +export type { ConfirmModalProps }; diff --git a/src/shared/ui/confirm-modal/ConfirmModal.types.ts b/src/shared/ui/confirm-modal/ConfirmModal.types.ts new file mode 100644 index 0000000..492f3fc --- /dev/null +++ b/src/shared/ui/confirm-modal/ConfirmModal.types.ts @@ -0,0 +1,56 @@ +import type { ComponentProps, ReactNode } from 'react'; + +import type { Button } from '@/shared/ui/button'; +import type { SurfaceCloseMeta, SurfaceOpenChangeMeta } from '@/shared/ui/surface'; + +type ConfirmModalButtonProps = Omit< + ComponentProps, + 'aria-label' | 'children' | 'disabled' | 'iconOnly' | 'isLoading' | 'onClick' +>; + +/** + * `ConfirmModal` 컴포넌트 props입니다. + * + * @param cancelLabel - 모달 하단에 표시할 취소 버튼 문구입니다. + * @param cancelButtonProps - 취소 버튼에 추가로 전달할 버튼 스타일 props입니다. + * @param className - 모달 컨테이너 스타일을 확장할 클래스입니다. + * @param closeOnEscape - ESC 키로 닫을 수 있는지 여부입니다. + * @param closeOnOutsideClick - 외부 영역 클릭으로 닫을 수 있는지 여부입니다. + * @param confirmLabel - 모달 하단에 표시할 확인 버튼 문구입니다. + * @param confirmButtonProps - 확인 버튼에 추가로 전달할 버튼 스타일 props입니다. + * @param defaultOpen - uncontrolled 방식에서 최초로 열어둘지 여부입니다. + * @param description - 제목 아래에 표시할 설명입니다. + * @param focusTrap - 열린 동안 포커스를 Content 내부에 가둘지 여부입니다. + * @param isLoading - 확인 작업 진행 여부입니다. true이면 중복 실행과 dismiss 닫기를 막습니다. + * @param onCancel - 취소 버튼 클릭 시 호출됩니다. + * @param onClosePrevented - 닫기 요청이 정책에 의해 차단됐을 때 호출됩니다. + * @param onConfirm - 확인 버튼 클릭 시 호출됩니다. + * @param onOpenChange - 열림 상태가 변경될 때 호출됩니다. + * @param open - controlled 방식으로 열림 상태를 제어할 때 사용합니다. + * @param restoreFocus - 닫힌 뒤 열기 전 포커스로 복원할지 여부입니다. + * @param scrollLock - body scroll lock 적용 여부입니다. + * @param title - 모달 제목입니다. + */ +interface ConfirmModalProps { + cancelLabel?: string; + cancelButtonProps?: ConfirmModalButtonProps; + className?: string; + closeOnEscape?: boolean; + closeOnOutsideClick?: boolean; + confirmLabel?: string; + confirmButtonProps?: ConfirmModalButtonProps; + defaultOpen?: boolean; + description?: ReactNode; + focusTrap?: boolean; + isLoading?: boolean; + onCancel?: () => void; + onClosePrevented?: (meta: SurfaceCloseMeta) => void; + onConfirm: () => void; + onOpenChange?: (open: boolean, meta: SurfaceOpenChangeMeta) => void; + open?: boolean; + restoreFocus?: boolean; + scrollLock?: boolean; + title: ReactNode; +} + +export type { ConfirmModalButtonProps, ConfirmModalProps }; diff --git a/src/shared/ui/confirm-modal/index.ts b/src/shared/ui/confirm-modal/index.ts new file mode 100644 index 0000000..18a6fe4 --- /dev/null +++ b/src/shared/ui/confirm-modal/index.ts @@ -0,0 +1,2 @@ +export { ConfirmModal } from './ConfirmModal'; +export type { ConfirmModalButtonProps, ConfirmModalProps } from './ConfirmModal.types'; From bc629debb57335a32b53247a238016aa7fb3e24e Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 20:25:58 +0900 Subject: [PATCH 03/13] =?UTF-8?q?=F0=9F=93=9D=20Docs:=20ConfirmModal=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=EB=B6=81=20=EC=98=88=EC=A0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/confirm-modal/ConfirmModal.stories.tsx | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/shared/ui/confirm-modal/ConfirmModal.stories.tsx diff --git a/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx new file mode 100644 index 0000000..2f5b303 --- /dev/null +++ b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx @@ -0,0 +1,188 @@ +import { useState } from 'react'; +import type { ComponentProps } from 'react'; + +import { Button } from '@/shared/ui/button'; + +import { ConfirmModal } from './index'; + +import type { Meta, StoryObj } from '@storybook/nextjs'; + +type ConfirmModalStoryProps = ComponentProps; + +function ButtonOpenConfirmModalExample(args: ConfirmModalStoryProps) { + const [isOpen, setIsOpen] = useState(false); + + const handleConfirm = () => { + args.onConfirm(); + setIsOpen(false); + }; + + const handleCancel = () => { + args.onCancel?.(); + }; + + return ( +
+ + +
+ ); +} + +function ControlledConfirmModalExample() { + const [isOpen, setIsOpen] = useState(false); + const [message, setMessage] = useState('대기 중'); + + const handleConfirm = () => { + setMessage('확인 이벤트가 실행되었습니다.'); + setIsOpen(false); + }; + + const handleCancel = () => { + setMessage('취소 이벤트가 실행되었습니다.'); + }; + + return ( +
+ +

{message}

+ +
+ ); +} + +function LoadingConfirmModalExample() { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + undefined} + onOpenChange={setIsOpen} + open={isOpen} + title="해당 자기소개서를 저장하시겠습니까?" + /> +
+ ); +} + +const meta = { + title: 'Shared/ConfirmModal', + component: ConfirmModal, + tags: ['autodocs'], + args: { + cancelLabel: '아니오', + confirmLabel: '저장하기', + defaultOpen: false, + description: '버전 V.0.1로 저장됩니다.', + isLoading: false, + onConfirm: () => undefined, + title: '해당 자기소개서를 저장하시겠습니까?', + }, + argTypes: { + cancelButtonProps: { + control: false, + }, + className: { + control: false, + }, + closeOnEscape: { + control: 'boolean', + }, + closeOnOutsideClick: { + control: 'boolean', + }, + confirmButtonProps: { + control: false, + }, + defaultOpen: { + control: 'boolean', + }, + description: { + control: 'text', + }, + focusTrap: { + control: 'boolean', + }, + isLoading: { + control: 'boolean', + }, + onCancel: { + action: 'cancel', + }, + onConfirm: { + action: 'confirm', + }, + onOpenChange: { + action: 'openChange', + }, + open: { + control: false, + }, + restoreFocus: { + control: 'boolean', + }, + scrollLock: { + control: 'boolean', + }, + title: { + control: 'text', + }, + }, + parameters: { + backgrounds: { + default: 'black', + }, + docs: { + description: { + component: + '확인/취소 선택이 필요한 짧은 의사결정 모달입니다. Surface의 modal variant를 기반으로 focus trap, scroll lock, dismiss 정책을 재사용합니다.', + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; + +export const Loading: Story = { + render: () => , +}; + +export const Controlled: Story = { + render: () => , +}; + +export const LongDescription: Story = { + args: { + description: + '현재 작성 중인 내용은 새 버전으로 저장되며, 저장이 완료된 뒤에는 버전 목록에서 다시 확인할 수 있습니다.', + title: '현재 내용을 새 버전으로 저장하시겠습니까?', + }, + render: (args) => , +}; From e9d3bb124b4e4463a04495544f5e744cc2c7b2da Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 20:49:20 +0900 Subject: [PATCH 04/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20ConfirmM?= =?UTF-8?q?odal=20=ED=83=80=EC=9E=85=20=EA=B3=B5=EA=B0=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/confirm-modal/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shared/ui/confirm-modal/index.ts b/src/shared/ui/confirm-modal/index.ts index 18a6fe4..3bd31f7 100644 --- a/src/shared/ui/confirm-modal/index.ts +++ b/src/shared/ui/confirm-modal/index.ts @@ -1,2 +1 @@ export { ConfirmModal } from './ConfirmModal'; -export type { ConfirmModalButtonProps, ConfirmModalProps } from './ConfirmModal.types'; From e2961834a067b94dffb1d52aaab40762fb77c9c3 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 20:56:48 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/styles/components/surface.css | 37 ++++++++++++++++++++++++ src/shared/styles/globals.css | 1 + src/shared/ui/surface/SurfaceContent.tsx | 1 + src/shared/ui/surface/SurfaceOverlay.tsx | 2 +- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/shared/styles/components/surface.css diff --git a/src/shared/styles/components/surface.css b/src/shared/styles/components/surface.css new file mode 100644 index 0000000..2a98bed --- /dev/null +++ b/src/shared/styles/components/surface.css @@ -0,0 +1,37 @@ +@keyframes surfaceOverlayFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes surfaceModalSlideUp { + from { + opacity: 0; + transform: translate3d(0, 40px, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +.surface-modal-overlay-animation { + animation: surfaceOverlayFadeIn 350ms ease-out both; +} + +.surface-modal-animation { + will-change: transform, opacity; + animation: surfaceModalSlideUp 350ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .surface-modal-overlay-animation, + .surface-modal-animation { + animation: none; + } +} diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index 1b58533..23fafe1 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -3,6 +3,7 @@ @import './base/colors.css'; @import './base/z-index.css'; @import './base/scroll.css'; +@import './components/surface.css'; button { cursor: pointer; diff --git a/src/shared/ui/surface/SurfaceContent.tsx b/src/shared/ui/surface/SurfaceContent.tsx index ee90bf6..7efeaf5 100644 --- a/src/shared/ui/surface/SurfaceContent.tsx +++ b/src/shared/ui/surface/SurfaceContent.tsx @@ -97,6 +97,7 @@ export function SurfaceContent({ state: state.isOpen ? 'open' : 'closed', variant: state.variant, }), + state.variant === 'modal' && 'surface-modal-animation', className )} data-state={state.isOpen ? 'open' : 'closed'} diff --git a/src/shared/ui/surface/SurfaceOverlay.tsx b/src/shared/ui/surface/SurfaceOverlay.tsx index cc5f31d..f43d1d5 100644 --- a/src/shared/ui/surface/SurfaceOverlay.tsx +++ b/src/shared/ui/surface/SurfaceOverlay.tsx @@ -29,7 +29,7 @@ export function SurfaceOverlay({ className, ref, ...props }: SurfaceOverlayProps {...props} aria-hidden="true" className={cn( - 'fixed inset-0 z-(--z-index-surface-overlay) bg-black/70 transition-opacity duration-150', + 'surface-modal-overlay-animation fixed inset-0 z-(--z-index-surface-overlay) bg-black/70', className )} data-state={state.isOpen ? 'open' : 'closed'} From 69e7e908672bea147a94548ecdd9151fa69ba0f3 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 20:57:36 +0900 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=9A=9A=20Rename:=20Slot=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=20=ED=8C=8C=EC=9D=BC=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/accordion/AccordionLabel.tsx | 2 +- src/shared/ui/surface/SurfaceClose.tsx | 2 +- src/shared/ui/surface/SurfaceTrigger.tsx | 2 +- src/shared/{ui/utils/Slot.ts => utils/slot.ts} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename src/shared/{ui/utils/Slot.ts => utils/slot.ts} (100%) diff --git a/src/shared/ui/accordion/AccordionLabel.tsx b/src/shared/ui/accordion/AccordionLabel.tsx index 97a2ae9..7084f7f 100644 --- a/src/shared/ui/accordion/AccordionLabel.tsx +++ b/src/shared/ui/accordion/AccordionLabel.tsx @@ -1,7 +1,7 @@ 'use client'; import { cn } from '@/shared/styles/utils/cn'; -import { cloneSlot, getSingleSlotChild } from '@/shared/ui/utils/Slot'; +import { cloneSlot, getSingleSlotChild } from '@/shared/utils/slot'; import { useAccordionContext } from './AccordionContext'; diff --git a/src/shared/ui/surface/SurfaceClose.tsx b/src/shared/ui/surface/SurfaceClose.tsx index f54d9bd..cd89844 100644 --- a/src/shared/ui/surface/SurfaceClose.tsx +++ b/src/shared/ui/surface/SurfaceClose.tsx @@ -2,7 +2,7 @@ import type { ComponentPropsWithRef } from 'react'; -import { cloneSlot, getSingleSlotChild, getSlotProps } from '@/shared/ui/utils/Slot'; +import { cloneSlot, getSingleSlotChild, getSlotProps } from '@/shared/utils/slot'; import { useSurfaceContext } from './SurfaceContext'; diff --git a/src/shared/ui/surface/SurfaceTrigger.tsx b/src/shared/ui/surface/SurfaceTrigger.tsx index d6331c9..aa88106 100644 --- a/src/shared/ui/surface/SurfaceTrigger.tsx +++ b/src/shared/ui/surface/SurfaceTrigger.tsx @@ -2,7 +2,7 @@ import type { ComponentPropsWithRef } from 'react'; -import { cloneSlot, getSingleSlotChild, getSlotProps } from '@/shared/ui/utils/Slot'; +import { cloneSlot, getSingleSlotChild, getSlotProps } from '@/shared/utils/slot'; import { useSurfaceContext } from './SurfaceContext'; diff --git a/src/shared/ui/utils/Slot.ts b/src/shared/utils/slot.ts similarity index 100% rename from src/shared/ui/utils/Slot.ts rename to src/shared/utils/slot.ts From bfa2af82957aba872b7691ebe4baca8d82a318e3 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 21:15:32 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20ButtonPr?= =?UTF-8?q?ops=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/confirm-modal/ConfirmModal.stories.tsx | 9 --------- src/shared/ui/confirm-modal/ConfirmModal.tsx | 12 +++--------- .../ui/confirm-modal/ConfirmModal.types.ts | 16 ++-------------- 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx index 2f5b303..1e50b36 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx +++ b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx @@ -100,21 +100,12 @@ const meta = { title: '해당 자기소개서를 저장하시겠습니까?', }, argTypes: { - cancelButtonProps: { - control: false, - }, - className: { - control: false, - }, closeOnEscape: { control: 'boolean', }, closeOnOutsideClick: { control: 'boolean', }, - confirmButtonProps: { - control: false, - }, defaultOpen: { control: 'boolean', }, diff --git a/src/shared/ui/confirm-modal/ConfirmModal.tsx b/src/shared/ui/confirm-modal/ConfirmModal.tsx index 6808c09..06e28db 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.tsx +++ b/src/shared/ui/confirm-modal/ConfirmModal.tsx @@ -2,7 +2,6 @@ import { useId } from 'react'; -import { cn } from '@/shared/styles/utils/cn'; import { Button } from '@/shared/ui/button'; import { Surface } from '@/shared/ui/surface'; import { Title } from '@/shared/ui/title'; @@ -39,12 +38,9 @@ import type { ConfirmModalProps } from './ConfirmModal.types'; * ``` */ export function ConfirmModal({ - cancelButtonProps, cancelLabel = '취소', - className, closeOnEscape = true, closeOnOutsideClick = true, - confirmButtonProps, confirmLabel = '확인', description, isLoading = false, @@ -71,7 +67,7 @@ export function ConfirmModal({ @@ -86,8 +82,7 @@ export function ConfirmModal({ <Surface.Footer className="grid w-full grid-cols-2 gap-2"> <Surface.Close aria-label={cancelLabel} asChild> <Button - {...cancelButtonProps} - className={cn('h-10 w-full', cancelButtonProps?.className)} + className="h-10 w-full" disabled={isCancelDisabled} onClick={onCancel} size="sm" @@ -97,8 +92,7 @@ export function ConfirmModal({ </Button> </Surface.Close> <Button - {...confirmButtonProps} - className={cn('h-10 w-full', confirmButtonProps?.className)} + className="h-10 w-full" isLoading={isLoading} onClick={onConfirm} size="sm" diff --git a/src/shared/ui/confirm-modal/ConfirmModal.types.ts b/src/shared/ui/confirm-modal/ConfirmModal.types.ts index 492f3fc..cd21052 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.types.ts +++ b/src/shared/ui/confirm-modal/ConfirmModal.types.ts @@ -1,23 +1,14 @@ -import type { ComponentProps, ReactNode } from 'react'; +import type { ReactNode } from 'react'; -import type { Button } from '@/shared/ui/button'; import type { SurfaceCloseMeta, SurfaceOpenChangeMeta } from '@/shared/ui/surface'; -type ConfirmModalButtonProps = Omit< - ComponentProps<typeof Button>, - 'aria-label' | 'children' | 'disabled' | 'iconOnly' | 'isLoading' | 'onClick' ->; - /** * `ConfirmModal` 컴포넌트 props입니다. * * @param cancelLabel - 모달 하단에 표시할 취소 버튼 문구입니다. - * @param cancelButtonProps - 취소 버튼에 추가로 전달할 버튼 스타일 props입니다. - * @param className - 모달 컨테이너 스타일을 확장할 클래스입니다. * @param closeOnEscape - ESC 키로 닫을 수 있는지 여부입니다. * @param closeOnOutsideClick - 외부 영역 클릭으로 닫을 수 있는지 여부입니다. * @param confirmLabel - 모달 하단에 표시할 확인 버튼 문구입니다. - * @param confirmButtonProps - 확인 버튼에 추가로 전달할 버튼 스타일 props입니다. * @param defaultOpen - uncontrolled 방식에서 최초로 열어둘지 여부입니다. * @param description - 제목 아래에 표시할 설명입니다. * @param focusTrap - 열린 동안 포커스를 Content 내부에 가둘지 여부입니다. @@ -33,12 +24,9 @@ type ConfirmModalButtonProps = Omit< */ interface ConfirmModalProps { cancelLabel?: string; - cancelButtonProps?: ConfirmModalButtonProps; - className?: string; closeOnEscape?: boolean; closeOnOutsideClick?: boolean; confirmLabel?: string; - confirmButtonProps?: ConfirmModalButtonProps; defaultOpen?: boolean; description?: ReactNode; focusTrap?: boolean; @@ -53,4 +41,4 @@ interface ConfirmModalProps { title: ReactNode; } -export type { ConfirmModalButtonProps, ConfirmModalProps }; +export type { ConfirmModalProps }; From 49593744be99e056ca092fcb21fb6a39958d349d Mon Sep 17 00:00:00 2001 From: Leeseojeong <sjung0314@gmail.com> Date: Mon, 29 Jun 2026 21:16:34 +0900 Subject: [PATCH 08/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20ConfirmM?= =?UTF-8?q?odal=20=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/confirm-modal/ConfirmModal.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shared/ui/confirm-modal/ConfirmModal.tsx b/src/shared/ui/confirm-modal/ConfirmModal.tsx index 06e28db..64be51e 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.tsx +++ b/src/shared/ui/confirm-modal/ConfirmModal.tsx @@ -52,7 +52,6 @@ export function ConfirmModal({ const generatedId = useId(); const titleId = `confirm-modal-title-${generatedId}`; const descriptionId = description ? `confirm-modal-description-${generatedId}` : undefined; - const isCancelDisabled = isLoading; return ( <Surface @@ -83,7 +82,7 @@ export function ConfirmModal({ <Surface.Close aria-label={cancelLabel} asChild> <Button className="h-10 w-full" - disabled={isCancelDisabled} + disabled={isLoading} onClick={onCancel} size="sm" variant="secondary" From 9736403a4454e6fa9c8d3982ca1021be0b43b89b Mon Sep 17 00:00:00 2001 From: Leeseojeong <sjung0314@gmail.com> Date: Mon, 29 Jun 2026 21:43:23 +0900 Subject: [PATCH 09/13] =?UTF-8?q?=F0=9F=93=9D=20Docs:=20ConfirmModal=20TSD?= =?UTF-8?q?ocs=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/main.ts | 3 ++ .../ui/confirm-modal/ConfirmModal.stories.tsx | 15 +++++--- src/shared/ui/confirm-modal/ConfirmModal.tsx | 17 +++++++++ .../ui/confirm-modal/ConfirmModal.types.ts | 37 +++++++++---------- 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/.storybook/main.ts b/.storybook/main.ts index 2c6f004..2e24938 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -13,6 +13,9 @@ const config: StorybookConfig = { options: {}, }, staticDirs: ['../public'], + typescript: { + reactDocgen: 'react-docgen-typescript', + }, webpackFinal: async (config) => { if (!config.module || !config.module.rules) { return config; diff --git a/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx index 1e50b36..d4231d9 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx +++ b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx @@ -100,12 +100,18 @@ const meta = { title: '해당 자기소개서를 저장하시겠습니까?', }, argTypes: { + cancelLabel: { + control: 'text', + }, closeOnEscape: { control: 'boolean', }, closeOnOutsideClick: { control: 'boolean', }, + confirmLabel: { + control: 'text', + }, defaultOpen: { control: 'boolean', }, @@ -121,6 +127,9 @@ const meta = { onCancel: { action: 'cancel', }, + onClosePrevented: { + action: 'closePrevented', + }, onConfirm: { action: 'confirm', }, @@ -144,12 +153,6 @@ const meta = { backgrounds: { default: 'black', }, - docs: { - description: { - component: - '확인/취소 선택이 필요한 짧은 의사결정 모달입니다. Surface의 modal variant를 기반으로 focus trap, scroll lock, dismiss 정책을 재사용합니다.', - }, - }, }, } satisfies Meta<typeof ConfirmModal>; diff --git a/src/shared/ui/confirm-modal/ConfirmModal.tsx b/src/shared/ui/confirm-modal/ConfirmModal.tsx index 64be51e..5a2b939 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.tsx +++ b/src/shared/ui/confirm-modal/ConfirmModal.tsx @@ -25,6 +25,23 @@ import type { ConfirmModalProps } from './ConfirmModal.types'; * * 제목과 설명은 각각 `aria-labelledby`, `aria-describedby`로 dialog에 연결됩니다. * + * @param cancelLabel - 모달 하단에 표시할 취소 버튼 문구입니다. + * @param closeOnEscape - ESC 키로 닫을 수 있는지 여부입니다. + * @param closeOnOutsideClick - 외부 영역 클릭으로 닫을 수 있는지 여부입니다. + * @param confirmLabel - 모달 하단에 표시할 확인 버튼 문구입니다. + * @param defaultOpen - uncontrolled 방식에서 최초로 열어둘지 여부입니다. + * @param description - 제목 아래에 표시할 설명입니다. + * @param focusTrap - 열린 동안 포커스를 Content 내부에 가둘지 여부입니다. + * @param isLoading - 확인 작업 진행 여부입니다. true이면 확인 버튼 로딩 표시와 dismiss 닫기 방지를 적용합니다. + * @param onCancel - 취소 버튼 클릭 시 호출됩니다. + * @param onClosePrevented - 닫기 요청이 정책에 의해 차단됐을 때 호출됩니다. + * @param onConfirm - 확인 버튼 클릭 시 호출됩니다. + * @param onOpenChange - 열림 상태가 변경될 때 호출됩니다. + * @param open - controlled 방식으로 열림 상태를 제어할 때 사용합니다. + * @param restoreFocus - 닫힌 뒤 열기 전 포커스로 복원할지 여부입니다. + * @param scrollLock - body scroll lock 적용 여부입니다. + * @param title - 모달 제목입니다. + * * @example * ```tsx * <ConfirmModal diff --git a/src/shared/ui/confirm-modal/ConfirmModal.types.ts b/src/shared/ui/confirm-modal/ConfirmModal.types.ts index cd21052..8dc5ab4 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.types.ts +++ b/src/shared/ui/confirm-modal/ConfirmModal.types.ts @@ -2,42 +2,39 @@ import type { ReactNode } from 'react'; import type { SurfaceCloseMeta, SurfaceOpenChangeMeta } from '@/shared/ui/surface'; -/** - * `ConfirmModal` 컴포넌트 props입니다. - * - * @param cancelLabel - 모달 하단에 표시할 취소 버튼 문구입니다. - * @param closeOnEscape - ESC 키로 닫을 수 있는지 여부입니다. - * @param closeOnOutsideClick - 외부 영역 클릭으로 닫을 수 있는지 여부입니다. - * @param confirmLabel - 모달 하단에 표시할 확인 버튼 문구입니다. - * @param defaultOpen - uncontrolled 방식에서 최초로 열어둘지 여부입니다. - * @param description - 제목 아래에 표시할 설명입니다. - * @param focusTrap - 열린 동안 포커스를 Content 내부에 가둘지 여부입니다. - * @param isLoading - 확인 작업 진행 여부입니다. true이면 중복 실행과 dismiss 닫기를 막습니다. - * @param onCancel - 취소 버튼 클릭 시 호출됩니다. - * @param onClosePrevented - 닫기 요청이 정책에 의해 차단됐을 때 호출됩니다. - * @param onConfirm - 확인 버튼 클릭 시 호출됩니다. - * @param onOpenChange - 열림 상태가 변경될 때 호출됩니다. - * @param open - controlled 방식으로 열림 상태를 제어할 때 사용합니다. - * @param restoreFocus - 닫힌 뒤 열기 전 포커스로 복원할지 여부입니다. - * @param scrollLock - body scroll lock 적용 여부입니다. - * @param title - 모달 제목입니다. - */ +/** ConfirmModal props입니다. */ interface ConfirmModalProps { + /** 모달 하단에 표시할 취소 버튼 문구입니다. */ cancelLabel?: string; + /** ESC 키로 닫을 수 있는지 여부입니다. */ closeOnEscape?: boolean; + /** 외부 영역 클릭으로 닫을 수 있는지 여부입니다. */ closeOnOutsideClick?: boolean; + /** 모달 하단에 표시할 확인 버튼 문구입니다. */ confirmLabel?: string; + /** uncontrolled 방식에서 최초로 열어둘지 여부입니다. */ defaultOpen?: boolean; + /** 제목 아래에 표시할 설명입니다. */ description?: ReactNode; + /** 열린 동안 포커스를 Content 내부에 가둘지 여부입니다. */ focusTrap?: boolean; + /** 확인 작업 진행 여부입니다. true이면 확인 버튼 로딩 표시와 dismiss 닫기 방지를 적용합니다. */ isLoading?: boolean; + /** 취소 버튼 클릭 시 호출됩니다. */ onCancel?: () => void; + /** 닫기 요청이 정책에 의해 차단됐을 때 호출됩니다. */ onClosePrevented?: (meta: SurfaceCloseMeta) => void; + /** 확인 버튼 클릭 시 호출됩니다. */ onConfirm: () => void; + /** 열림 상태가 변경될 때 호출됩니다. */ onOpenChange?: (open: boolean, meta: SurfaceOpenChangeMeta) => void; + /** controlled 방식으로 열림 상태를 제어할 때 사용합니다. */ open?: boolean; + /** 닫힌 뒤 열기 전 포커스로 복원할지 여부입니다. */ restoreFocus?: boolean; + /** body scroll lock 적용 여부입니다. */ scrollLock?: boolean; + /** 모달 제목입니다. */ title: ReactNode; } From 6065d57730875ef3f13a50fe9ca88e390c53fc71 Mon Sep 17 00:00:00 2001 From: Leeseojeong <sjung0314@gmail.com> Date: Tue, 30 Jun 2026 10:58:23 +0900 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=8E=A8=20Style:=20ConfirmModal=20ga?= =?UTF-8?q?p=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/confirm-modal/ConfirmModal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/ui/confirm-modal/ConfirmModal.tsx b/src/shared/ui/confirm-modal/ConfirmModal.tsx index 5a2b939..11a3708 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.tsx +++ b/src/shared/ui/confirm-modal/ConfirmModal.tsx @@ -30,7 +30,7 @@ import type { ConfirmModalProps } from './ConfirmModal.types'; * @param closeOnOutsideClick - 외부 영역 클릭으로 닫을 수 있는지 여부입니다. * @param confirmLabel - 모달 하단에 표시할 확인 버튼 문구입니다. * @param defaultOpen - uncontrolled 방식에서 최초로 열어둘지 여부입니다. - * @param description - 제목 아래에 표시할 설명입니다. + * @param description - 제목 아래에 표시할 짧은 설명 문구입니다. * @param focusTrap - 열린 동안 포커스를 Content 내부에 가둘지 여부입니다. * @param isLoading - 확인 작업 진행 여부입니다. true이면 확인 버튼 로딩 표시와 dismiss 닫기 방지를 적용합니다. * @param onCancel - 취소 버튼 클릭 시 호출됩니다. @@ -83,9 +83,9 @@ export function ConfirmModal({ <Surface.Content aria-describedby={descriptionId} aria-labelledby={titleId} - className="w-80 items-center gap-5 bg-gray-800 p-6 text-center" + className="w-80 items-center gap-8 bg-gray-800 p-6 text-center" > - <Surface.Header className="flex flex-col items-center gap-5"> + <Surface.Header className="flex flex-col items-center gap-3"> <Title as="h2" className="body-18 font-bold" id={titleId}> {title} From 9a9788d40eaf53564f9cb00c95225c33b7f038de Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Tue, 30 Jun 2026 10:58:49 +0900 Subject: [PATCH 11/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20descript?= =?UTF-8?q?ion=20=ED=83=80=EC=9E=85=20string=EC=9C=BC=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/confirm-modal/ConfirmModal.types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/ui/confirm-modal/ConfirmModal.types.ts b/src/shared/ui/confirm-modal/ConfirmModal.types.ts index 8dc5ab4..13913b0 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.types.ts +++ b/src/shared/ui/confirm-modal/ConfirmModal.types.ts @@ -14,8 +14,8 @@ interface ConfirmModalProps { confirmLabel?: string; /** uncontrolled 방식에서 최초로 열어둘지 여부입니다. */ defaultOpen?: boolean; - /** 제목 아래에 표시할 설명입니다. */ - description?: ReactNode; + /** 제목 아래에 표시할 짧은 설명 문구입니다. */ + description?: string; /** 열린 동안 포커스를 Content 내부에 가둘지 여부입니다. */ focusTrap?: boolean; /** 확인 작업 진행 여부입니다. true이면 확인 버튼 로딩 표시와 dismiss 닫기 방지를 적용합니다. */ From 9262564ecc9260d0962d3ab77971a36246c9c15a Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Tue, 30 Jun 2026 11:05:50 +0900 Subject: [PATCH 12/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Surface?= =?UTF-8?q?=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=ED=82=A4?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=9E=84=20=EC=9D=B4=EB=A6=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/styles/components/surface.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shared/styles/components/surface.css b/src/shared/styles/components/surface.css index 2a98bed..185bb55 100644 --- a/src/shared/styles/components/surface.css +++ b/src/shared/styles/components/surface.css @@ -1,4 +1,4 @@ -@keyframes surfaceOverlayFadeIn { +@keyframes surface-overlay-fade-in { from { opacity: 0; } @@ -8,7 +8,7 @@ } } -@keyframes surfaceModalSlideUp { +@keyframes surface-modal-slide-up { from { opacity: 0; transform: translate3d(0, 40px, 0); @@ -21,12 +21,12 @@ } .surface-modal-overlay-animation { - animation: surfaceOverlayFadeIn 350ms ease-out both; + animation: surface-overlay-fade-in 350ms ease-out both; } .surface-modal-animation { will-change: transform, opacity; - animation: surfaceModalSlideUp 350ms ease-out both; + animation: surface-modal-slide-up 350ms ease-out both; } @media (prefers-reduced-motion: reduce) { From 0c5dd49f58482160d1dd3e35b86dae274831cdb3 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Tue, 30 Jun 2026 11:09:22 +0900 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=93=9D=20Docs:=20ConfirmModal=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/confirm-modal/ConfirmModal.stories.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx index d4231d9..5063868 100644 --- a/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx +++ b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { ComponentProps } from 'react'; import { Button } from '@/shared/ui/button'; @@ -68,6 +68,26 @@ function ControlledConfirmModalExample() { function LoadingConfirmModalExample() { const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + const handleConfirm = () => { + setIsLoading(true); + + timerRef.current = setTimeout(() => { + setIsLoading(false); + setIsOpen(false); + timerRef.current = null; + }, 1200); + }; return (
@@ -76,8 +96,8 @@ function LoadingConfirmModalExample() { cancelLabel="아니오" confirmLabel="저장하기" description="저장 중에는 모달을 닫을 수 없습니다." - isLoading - onConfirm={() => undefined} + isLoading={isLoading} + onConfirm={handleConfirm} onOpenChange={setIsOpen} open={isOpen} title="해당 자기소개서를 저장하시겠습니까?"