Skip to content

Commit 9ab7321

Browse files
authored
Feat(design-system): 팝업 공통 컴포넌트 (#39)
* feat: 팝업 분기 초기 세팅 * feat: 팝업 레이아웃 퍼블리싱 * feat: 포인터 커서 * feat: 팝업 타입 별 분기 * feat: 스토리북 코드 연결 * fix: app 기본 형태로 복구 * feat: createPortal활용 팝업 오버레이 구성 * feat: 타입 분리 수정 및 스토리북 중앙배치 * chore: 빌드에러 수정
1 parent deaa027 commit 9ab7321

File tree

8 files changed

+294
-5
lines changed

8 files changed

+294
-5
lines changed

package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@
2121
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
2222
"@types/react": "19",
2323
"@types/react-dom": "19",
24+
"@vitest/browser": "^3.2.4",
25+
"@vitest/coverage-v8": "^3.2.4",
26+
"playwright": "^1.55.0",
2427
"prettier": "^3.6.2",
2528
"prettier-plugin-tailwindcss": "^0.6.14",
2629
"storybook": "^9.1.3",
2730
"turbo": "^2.5.6",
2831
"typescript": "5.9.2",
2932
"vite": "7.1.2",
30-
"vitest": "^3.2.4",
31-
"@vitest/browser": "^3.2.4",
32-
"playwright": "^1.55.0",
33-
"@vitest/coverage-v8": "^3.2.4"
33+
"vitest": "^3.2.4"
3434
},
3535
"pnpm": {
3636
"overrides": {
@@ -40,5 +40,9 @@
4040
"packageManager": "pnpm@9.0.0",
4141
"engines": {
4242
"node": ">=18"
43+
},
44+
"dependencies": {
45+
"@types/react-router-dom": "^5.3.3",
46+
"react-router-dom": "^7.8.2"
4347
}
4448
}

packages/design-system/src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ export { default as AutoDismissToast } from './toast/hooks/uesFadeOut';
1111
export { default as Toast } from './toast/Toast';
1212
export { WheelPicker, WheelPickerWrapper } from './wheelPicker/WheelPicker';
1313
export type { WheelPickerOption } from './wheelPicker/WheelPicker';
14+
export { default as Popup } from './popup/Popup';
15+
export { default as PopupContainer } from './popup/PopupContainer';

packages/design-system/src/components/input/Input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { InputHTMLAttributes, Ref } from 'react';
33
import { cn } from '../../lib';
44

55
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
6-
ref: Ref<HTMLInputElement>;
6+
ref?: Ref<HTMLInputElement>;
77
isError?: boolean;
88
helperText?: string;
99
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import Popup from './Popup';
3+
4+
const meta: Meta<typeof Popup> = {
5+
title: 'Components/Popup',
6+
component: Popup,
7+
tags: ['autodocs'],
8+
parameters: {
9+
layout: 'centered',
10+
},
11+
argTypes: {
12+
type: {
13+
control: 'radio',
14+
options: ['input', 'subtext', 'default'],
15+
description: '팝업의 내용 타입',
16+
},
17+
title: {
18+
control: 'text',
19+
description: '팝업 제목',
20+
},
21+
subtext: {
22+
control: 'text',
23+
description: 'subtext 타입일 때 보여줄 보조 문구',
24+
},
25+
placeholder: {
26+
control: 'text',
27+
description: 'input 타입일 때 placeholder',
28+
},
29+
left: {
30+
control: 'text',
31+
description: '왼쪽 버튼 텍스트',
32+
},
33+
right: {
34+
control: 'text',
35+
description: '오른쪽 버튼 텍스트',
36+
},
37+
isError: {
38+
control: 'boolean',
39+
description: 'input 타입일 때 에러 상태 여부',
40+
},
41+
helperText: {
42+
control: 'text',
43+
description: '에러 상태일 때 표시할 도움말',
44+
},
45+
},
46+
};
47+
export default meta;
48+
49+
type Story = StoryObj<typeof Popup>;
50+
51+
export const Default: Story = {
52+
args: {
53+
type: 'default',
54+
title: '기본 팝업',
55+
left: '취소',
56+
right: '확인',
57+
},
58+
};
59+
60+
export const WithInput: Story = {
61+
args: {
62+
type: 'input',
63+
title: '카테고리 입력',
64+
placeholder: '카테고리 제목을 입력해주세요',
65+
left: '취소',
66+
right: '저장',
67+
},
68+
};
69+
70+
export const WithErrorInput: Story = {
71+
args: {
72+
type: 'input',
73+
title: '카테고리 입력',
74+
placeholder: '카테고리 제목을 입력해주세요',
75+
isError: true,
76+
helperText: '10자 이내로 입력해주세요',
77+
left: '취소',
78+
right: '저장',
79+
},
80+
};
81+
82+
export const WithSubtext: Story = {
83+
args: {
84+
type: 'subtext',
85+
title: '알림',
86+
subtext: '카테고리가 정상적으로 저장되었습니다.',
87+
left: '닫기',
88+
right: '확인',
89+
},
90+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Input from '../input/Input';
2+
3+
type PopupType = 'input' | 'subtext' | 'default';
4+
5+
interface BasePopupProps {
6+
type: PopupType;
7+
title: string;
8+
left: string;
9+
right: string;
10+
subtext?: string;
11+
placeholder?: string;
12+
isError?: boolean;
13+
helperText?: string;
14+
onLeftClick?: () => void;
15+
onRightClick?: () => void;
16+
}
17+
const Popup = ({
18+
type,
19+
subtext,
20+
placeholder,
21+
title,
22+
left,
23+
right,
24+
helperText,
25+
isError,
26+
onLeftClick,
27+
onRightClick,
28+
}: BasePopupProps) => {
29+
return (
30+
<div className="bg-white-bg flex w-[26rem] cursor-pointer flex-col items-center justify-center rounded-[1.2rem] bg-white px-[1.6rem] py-[2.4rem] shadow-[0_0_32px_0_rgba(0,0,0,0.10)]">
31+
<div className="sub2-sb text-font-black-1 pb-[0.8rem]">{title}</div>
32+
{type === 'input' && (
33+
<div className="w-full py-[0.8rem]">
34+
<Input
35+
placeholder={placeholder}
36+
helperText={helperText}
37+
isError={isError}
38+
/>
39+
</div>
40+
)}
41+
{type === 'subtext' && (
42+
<div className="body3-r text-font-gray-2 w-full py-[0.8rem] text-center">
43+
{subtext}
44+
</div>
45+
)}
46+
{/* type===default일 떄는 아무것도 없음 */}
47+
<div className="flex flex-row items-center justify-center gap-[1.2rem] pt-[0.8rem]">
48+
<button
49+
className="border-gray200 sub5-sb bg-white-bg text-font-black-1 w-[10.8rem] rounded-[0.4rem] border py-[0.85rem]"
50+
onClick={onLeftClick}
51+
>
52+
{left}
53+
</button>
54+
<button
55+
className="sub5-sb bg-gray900 text-white-bg w-[10.8rem] rounded-[0.4rem] py-[0.85rem]"
56+
onClick={onRightClick}
57+
>
58+
{right}
59+
</button>
60+
</div>
61+
</div>
62+
);
63+
};
64+
65+
export default Popup;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { useState } from 'react';
3+
import PopupContainer from './PopupContainer';
4+
5+
const PopupContainerWrapper = (
6+
props: React.ComponentProps<typeof PopupContainer>
7+
) => <PopupContainer {...props} />;
8+
9+
const meta: Meta<typeof PopupContainerWrapper> = {
10+
title: 'Components/PopupContainer',
11+
component: PopupContainerWrapper,
12+
tags: ['autodocs'],
13+
parameters: {
14+
layout: 'centered',
15+
},
16+
};
17+
export default meta;
18+
19+
type Story = StoryObj<typeof PopupContainer>;
20+
21+
// 인터랙션 가능한 예시
22+
const WithTriggerButtonComponent = () => {
23+
const [open, setOpen] = useState(false);
24+
25+
return (
26+
<div className="flex flex-col items-center gap-4">
27+
<button
28+
className="rounded bg-blue-500 px-4 py-2 text-white"
29+
onClick={() => setOpen(true)}
30+
>
31+
팝업 열기
32+
</button>
33+
34+
<PopupContainer
35+
isOpen={open}
36+
onClose={() => setOpen(false)}
37+
type="input"
38+
title="카테고리 입력"
39+
left="취소"
40+
right="저장"
41+
placeholder="카테고리 제목을 입력해주세요"
42+
/>
43+
</div>
44+
);
45+
};
46+
47+
export const WithTriggerButton: Story = {
48+
render: () => <WithTriggerButtonComponent />,
49+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useEffect } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import Popup from './Popup';
4+
type PopupType = 'input' | 'subtext' | 'default';
5+
6+
interface BasePopupProps {
7+
type: PopupType;
8+
title: string;
9+
left: string;
10+
right: string;
11+
subtext?: string;
12+
placeholder?: string;
13+
isError?: boolean;
14+
helperText?: string;
15+
onLeftClick?: () => void;
16+
onRightClick?: () => void;
17+
}
18+
interface PopupContainerProps extends BasePopupProps {
19+
isOpen: boolean;
20+
onClose: () => void;
21+
}
22+
const PopupContainer = ({
23+
isOpen,
24+
onClose,
25+
...popupProps
26+
}: PopupContainerProps) => {
27+
// ESC 키로 닫는 것 정도 (외부 클릭은 안되게! : 어차피 x박스나 취소버튼이 있음)
28+
useEffect(() => {
29+
if (!isOpen) return;
30+
const handleEsc = (e: KeyboardEvent) => {
31+
if (e.key === 'Escape') onClose();
32+
};
33+
window.addEventListener('keydown', handleEsc);
34+
return () => window.removeEventListener('keydown', handleEsc);
35+
}, [isOpen, onClose]);
36+
37+
if (!isOpen) return null;
38+
39+
return createPortal(
40+
<div className="fixed inset-0 z-10 flex items-center justify-center">
41+
<div className="absolute inset-0 bg-[#00000099]" />
42+
<div className="relative">
43+
<Popup {...popupProps} onLeftClick={onClose} />
44+
</div>
45+
</div>,
46+
document.body // body 위에서 렌더링 되게 함!
47+
);
48+
};
49+
50+
export default PopupContainer;

pnpm-lock.yaml

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)