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/styles/components/surface.css b/src/shared/styles/components/surface.css new file mode 100644 index 0000000..185bb55 --- /dev/null +++ b/src/shared/styles/components/surface.css @@ -0,0 +1,37 @@ +@keyframes surface-overlay-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes surface-modal-slide-up { + from { + opacity: 0; + transform: translate3d(0, 40px, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +.surface-modal-overlay-animation { + animation: surface-overlay-fade-in 350ms ease-out both; +} + +.surface-modal-animation { + will-change: transform, opacity; + animation: surface-modal-slide-up 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/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/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', 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..5063868 --- /dev/null +++ b/src/shared/ui/confirm-modal/ConfirmModal.stories.tsx @@ -0,0 +1,202 @@ +import { useEffect, useRef, 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); + 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 ( +
+ + +
+ ); +} + +const meta = { + title: 'Shared/ConfirmModal', + component: ConfirmModal, + tags: ['autodocs'], + args: { + cancelLabel: '아니오', + confirmLabel: '저장하기', + defaultOpen: false, + description: '버전 V.0.1로 저장됩니다.', + isLoading: false, + onConfirm: () => undefined, + title: '해당 자기소개서를 저장하시겠습니까?', + }, + argTypes: { + cancelLabel: { + control: 'text', + }, + closeOnEscape: { + control: 'boolean', + }, + closeOnOutsideClick: { + control: 'boolean', + }, + confirmLabel: { + control: 'text', + }, + defaultOpen: { + control: 'boolean', + }, + description: { + control: 'text', + }, + focusTrap: { + control: 'boolean', + }, + isLoading: { + control: 'boolean', + }, + onCancel: { + action: 'cancel', + }, + onClosePrevented: { + action: 'closePrevented', + }, + onConfirm: { + action: 'confirm', + }, + onOpenChange: { + action: 'openChange', + }, + open: { + control: false, + }, + restoreFocus: { + control: 'boolean', + }, + scrollLock: { + control: 'boolean', + }, + title: { + control: 'text', + }, + }, + parameters: { + backgrounds: { + default: 'black', + }, + }, +} 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) => , +}; diff --git a/src/shared/ui/confirm-modal/ConfirmModal.tsx b/src/shared/ui/confirm-modal/ConfirmModal.tsx new file mode 100644 index 0000000..11a3708 --- /dev/null +++ b/src/shared/ui/confirm-modal/ConfirmModal.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useId } from 'react'; + +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에 연결됩니다. + * + * @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 + * + * ``` + */ +export function ConfirmModal({ + cancelLabel = '취소', + closeOnEscape = true, + closeOnOutsideClick = true, + 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; + + 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..13913b0 --- /dev/null +++ b/src/shared/ui/confirm-modal/ConfirmModal.types.ts @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; + +import type { SurfaceCloseMeta, SurfaceOpenChangeMeta } from '@/shared/ui/surface'; + +/** ConfirmModal props입니다. */ +interface ConfirmModalProps { + /** 모달 하단에 표시할 취소 버튼 문구입니다. */ + cancelLabel?: string; + /** ESC 키로 닫을 수 있는지 여부입니다. */ + closeOnEscape?: boolean; + /** 외부 영역 클릭으로 닫을 수 있는지 여부입니다. */ + closeOnOutsideClick?: boolean; + /** 모달 하단에 표시할 확인 버튼 문구입니다. */ + confirmLabel?: string; + /** uncontrolled 방식에서 최초로 열어둘지 여부입니다. */ + defaultOpen?: boolean; + /** 제목 아래에 표시할 짧은 설명 문구입니다. */ + description?: string; + /** 열린 동안 포커스를 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; +} + +export type { 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..3bd31f7 --- /dev/null +++ b/src/shared/ui/confirm-modal/index.ts @@ -0,0 +1 @@ +export { ConfirmModal } from './ConfirmModal'; 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/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'} 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