diff --git a/package.json b/package.json index 133f6c0b..3757db80 100644 --- a/package.json +++ b/package.json @@ -21,16 +21,16 @@ "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/react": "19", "@types/react-dom": "19", + "@vitest/browser": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4", + "playwright": "^1.55.0", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", "storybook": "^9.1.3", "turbo": "^2.5.6", "typescript": "5.9.2", "vite": "7.1.2", - "vitest": "^3.2.4", - "@vitest/browser": "^3.2.4", - "playwright": "^1.55.0", - "@vitest/coverage-v8": "^3.2.4" + "vitest": "^3.2.4" }, "pnpm": { "overrides": { @@ -40,5 +40,9 @@ "packageManager": "pnpm@9.0.0", "engines": { "node": ">=18" + }, + "dependencies": { + "@types/react-router-dom": "^5.3.3", + "react-router-dom": "^7.8.2" } } diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 559f94ad..b48d89af 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -11,3 +11,5 @@ export { default as AutoDismissToast } from './toast/hooks/uesFadeOut'; export { default as Toast } from './toast/Toast'; export { WheelPicker, WheelPickerWrapper } from './wheelPicker/WheelPicker'; export type { WheelPickerOption } from './wheelPicker/WheelPicker'; +export { default as Popup } from './popup/Popup'; +export { default as PopupContainer } from './popup/PopupContainer'; diff --git a/packages/design-system/src/components/input/Input.tsx b/packages/design-system/src/components/input/Input.tsx index 5e4d8f7f..3ecb13ff 100644 --- a/packages/design-system/src/components/input/Input.tsx +++ b/packages/design-system/src/components/input/Input.tsx @@ -3,7 +3,7 @@ import { InputHTMLAttributes, Ref } from 'react'; import { cn } from '../../lib'; interface InputProps extends InputHTMLAttributes { - ref: Ref; + ref?: Ref; isError?: boolean; helperText?: string; } diff --git a/packages/design-system/src/components/popup/Popup.stories.tsx b/packages/design-system/src/components/popup/Popup.stories.tsx new file mode 100644 index 00000000..86f2b8d9 --- /dev/null +++ b/packages/design-system/src/components/popup/Popup.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import Popup from './Popup'; + +const meta: Meta = { + title: 'Components/Popup', + component: Popup, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + type: { + control: 'radio', + options: ['input', 'subtext', 'default'], + description: '팝업의 내용 타입', + }, + title: { + control: 'text', + description: '팝업 제목', + }, + subtext: { + control: 'text', + description: 'subtext 타입일 때 보여줄 보조 문구', + }, + placeholder: { + control: 'text', + description: 'input 타입일 때 placeholder', + }, + left: { + control: 'text', + description: '왼쪽 버튼 텍스트', + }, + right: { + control: 'text', + description: '오른쪽 버튼 텍스트', + }, + isError: { + control: 'boolean', + description: 'input 타입일 때 에러 상태 여부', + }, + helperText: { + control: 'text', + description: '에러 상태일 때 표시할 도움말', + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + type: 'default', + title: '기본 팝업', + left: '취소', + right: '확인', + }, +}; + +export const WithInput: Story = { + args: { + type: 'input', + title: '카테고리 입력', + placeholder: '카테고리 제목을 입력해주세요', + left: '취소', + right: '저장', + }, +}; + +export const WithErrorInput: Story = { + args: { + type: 'input', + title: '카테고리 입력', + placeholder: '카테고리 제목을 입력해주세요', + isError: true, + helperText: '10자 이내로 입력해주세요', + left: '취소', + right: '저장', + }, +}; + +export const WithSubtext: Story = { + args: { + type: 'subtext', + title: '알림', + subtext: '카테고리가 정상적으로 저장되었습니다.', + left: '닫기', + right: '확인', + }, +}; diff --git a/packages/design-system/src/components/popup/Popup.tsx b/packages/design-system/src/components/popup/Popup.tsx new file mode 100644 index 00000000..d0f61724 --- /dev/null +++ b/packages/design-system/src/components/popup/Popup.tsx @@ -0,0 +1,65 @@ +import Input from '../input/Input'; + +type PopupType = 'input' | 'subtext' | 'default'; + +interface BasePopupProps { + type: PopupType; + title: string; + left: string; + right: string; + subtext?: string; + placeholder?: string; + isError?: boolean; + helperText?: string; + onLeftClick?: () => void; + onRightClick?: () => void; +} +const Popup = ({ + type, + subtext, + placeholder, + title, + left, + right, + helperText, + isError, + onLeftClick, + onRightClick, +}: BasePopupProps) => { + return ( +
+
{title}
+ {type === 'input' && ( +
+ +
+ )} + {type === 'subtext' && ( +
+ {subtext} +
+ )} + {/* type===default일 떄는 아무것도 없음 */} +
+ + +
+
+ ); +}; + +export default Popup; diff --git a/packages/design-system/src/components/popup/PopupContainer.stories.tsx b/packages/design-system/src/components/popup/PopupContainer.stories.tsx new file mode 100644 index 00000000..d9a7343a --- /dev/null +++ b/packages/design-system/src/components/popup/PopupContainer.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import PopupContainer from './PopupContainer'; + +const PopupContainerWrapper = ( + props: React.ComponentProps +) => ; + +const meta: Meta = { + title: 'Components/PopupContainer', + component: PopupContainerWrapper, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, +}; +export default meta; + +type Story = StoryObj; + +// 인터랙션 가능한 예시 +const WithTriggerButtonComponent = () => { + const [open, setOpen] = useState(false); + + return ( +
+ + + setOpen(false)} + type="input" + title="카테고리 입력" + left="취소" + right="저장" + placeholder="카테고리 제목을 입력해주세요" + /> +
+ ); +}; + +export const WithTriggerButton: Story = { + render: () => , +}; diff --git a/packages/design-system/src/components/popup/PopupContainer.tsx b/packages/design-system/src/components/popup/PopupContainer.tsx new file mode 100644 index 00000000..7dfbcbc8 --- /dev/null +++ b/packages/design-system/src/components/popup/PopupContainer.tsx @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import Popup from './Popup'; +type PopupType = 'input' | 'subtext' | 'default'; + +interface BasePopupProps { + type: PopupType; + title: string; + left: string; + right: string; + subtext?: string; + placeholder?: string; + isError?: boolean; + helperText?: string; + onLeftClick?: () => void; + onRightClick?: () => void; +} +interface PopupContainerProps extends BasePopupProps { + isOpen: boolean; + onClose: () => void; +} +const PopupContainer = ({ + isOpen, + onClose, + ...popupProps +}: PopupContainerProps) => { + // ESC 키로 닫는 것 정도 (외부 클릭은 안되게! : 어차피 x박스나 취소버튼이 있음) + useEffect(() => { + if (!isOpen) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleEsc); + return () => window.removeEventListener('keydown', handleEsc); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return createPortal( +
+
+
+ +
+
, + document.body // body 위에서 렌더링 되게 함! + ); +}; + +export default PopupContainer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0b72f7a..2a6e2695 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,13 @@ overrides: importers: .: + dependencies: + '@types/react-router-dom': + specifier: ^5.3.3 + version: 5.3.3 + react-router-dom: + specifier: ^7.8.2 + version: 7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) devDependencies: '@chromatic-com/storybook': specifier: ^4.1.1 @@ -1526,6 +1533,9 @@ packages: '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/history@4.7.11': + resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + '@types/inquirer@6.5.0': resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} @@ -1558,6 +1568,12 @@ packages: peerDependencies: '@types/react': ^19.0.0 + '@types/react-router-dom@5.3.3': + resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} + + '@types/react-router@5.1.20': + resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} + '@types/react@18.3.23': resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} @@ -5514,6 +5530,8 @@ snapshots: '@types/har-format@1.2.16': {} + '@types/history@4.7.11': {} + '@types/inquirer@6.5.0': dependencies: '@types/through': 0.0.33 @@ -5543,6 +5561,17 @@ snapshots: dependencies: '@types/react': 19.1.10 + '@types/react-router-dom@5.3.3': + dependencies: + '@types/history': 4.7.11 + '@types/react': 19.1.10 + '@types/react-router': 5.1.20 + + '@types/react-router@5.1.20': + dependencies: + '@types/history': 4.7.11 + '@types/react': 19.1.10 + '@types/react@18.3.23': dependencies: '@types/prop-types': 15.7.15