Skip to content

Commit 88b3376

Browse files
authored
[feat] 공통 컴포넌트 모달 구현 (#98)
* ✨ Feat: 간격 lx 28px 확장 * 📦️ Chore: dialog 패키지 설치 * 💄 Style: radius 20px 추가 * 💄 Style: border, spacing 28px, 20px 추가 * ✨ Feat: 모달 컴포넌트 구현, 커스텀 모달 구현 * ✨ Feat: 공통 컴포넌트 버튼 - dialog 버튼 옵션 추가 * ✨ Feat: 세팅 다이얼로그 추가 * ✨ Feat: 공통 모달, 프로그래밍식 닫기 지원 example에 지원 설명
1 parent 5c42f0a commit 88b3376

File tree

11 files changed

+1409
-1695
lines changed

11 files changed

+1409
-1695
lines changed

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"dependencies": {
1717
"@hookform/resolvers": "^4.1.3",
1818
"@radix-ui/react-avatar": "^1.1.3",
19+
"@radix-ui/react-dialog": "^1.1.6",
1920
"@radix-ui/react-dropdown-menu": "^2.1.6",
2021
"@radix-ui/react-label": "^2.1.2",
2122
"@radix-ui/react-progress": "^1.1.2",
@@ -33,8 +34,8 @@
3334
"next-themes": "^0.4.4",
3435
"react": "^18",
3536
"react-dom": "^18",
36-
"react-intersection-observer": "^9.16.0",
3737
"react-hook-form": "^7.54.2",
38+
"react-intersection-observer": "^9.16.0",
3839
"react-select": "^5.10.1",
3940
"sonner": "^2.0.1",
4041
"swiper": "^11.2.4",

pnpm-lock.yaml

+1,091-1,693
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/globals.css

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
/* radius */
6565
--radius-sm: 8px;
6666
--radius-md: 12px;
67+
--radius-ml: 20px;
6768
--radius-lg: 80px;
6869

6970
.swiper-pagination-bullet {
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client';
2+
3+
import CommonDialog from '@/shared/ui/dialog/commonDialog';
4+
5+
interface LogoutDialogProps {
6+
trigger: React.ReactNode;
7+
onConfirm?: () => void;
8+
}
9+
10+
const LogoutDialog = ({ trigger, onConfirm }: LogoutDialogProps) => {
11+
const handleConfirm = () => {
12+
onConfirm?.();
13+
};
14+
15+
return (
16+
<CommonDialog title="로그아웃" trigger={trigger} onConfirm={handleConfirm} actionText="확인" cancelText="취소">
17+
<div>지금 로그아웃 하시겠어요?</div>
18+
</CommonDialog>
19+
);
20+
};
21+
22+
export default LogoutDialog;

src/features/setting/ui/index.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1+
'use client';
2+
13
import { Label } from '@radix-ui/react-label';
4+
import { useRouter } from 'next/navigation';
25

36
import { cn } from '@/shared/lib/utils';
47
import { spacingStyles } from '@/shared/spacing';
58
import { List, ListItem, ListItemText } from '@/shared/ui/list';
69
import { ArrowBtn } from '@/shared/ui/list/wrappedList';
710

11+
import LogoutDialog from './dialog/logout';
12+
813
const SettingView = () => {
14+
const router = useRouter();
15+
const handleLogout = () => {
16+
router.push('/login');
17+
};
18+
919
return (
1020
<List variant="settingItem">
1121
<Label className="text-body-3 text-gray-400">기타</Label>
@@ -14,7 +24,7 @@ const SettingView = () => {
1424
<SettingItem text="개인정보처리약관" />
1525

1626
<Label className={cn('text-body-3 text-gray-400', spacingStyles({ marginTop: 'ms' }))}>계정</Label>
17-
<SettingItem text="로그아웃" />
27+
<LogoutDialog trigger={<SettingItem text="로그아웃" />} onConfirm={handleLogout} />
1828
<SettingItem text="회원 탈퇴" />
1929
</List>
2030
);

src/shared/spacing/spacing.ts

+14
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const spacingStyles = cva('', {
99
md: 'm-md', // margin: 16px;
1010
ml: 'm-ml', // margin: 20px;
1111
lg: 'm-lg', // margin: 24px;
12+
lx: 'm-lx', // margin: 28px;
1213
xl: 'm-xl', // margin: 32px;
1314
'2xl': 'm-2xl', // margin: 48px;
1415
},
@@ -19,6 +20,7 @@ export const spacingStyles = cva('', {
1920
md: 'p-md', // padding: 16px;
2021
ml: 'p-ml', // padding: 20px;
2122
lg: 'p-lg', // padding: 24px;
23+
lx: 'p-lx', // padding: 28px;
2224
xl: 'p-xl', // padding: 32px;
2325
'2xl': 'p-2xl', // padding: 48px;
2426
},
@@ -29,6 +31,7 @@ export const spacingStyles = cva('', {
2931
md: 'mx-md', // margin-left: 16px; margin-right: 16px;
3032
ml: 'mx-ml', // margin-left: 20px; margin-right: 20px;
3133
lg: 'mx-lg', // margin-left: 24px; margin-right: 24px;
34+
lx: 'mx-lx', // margin-left: 28px; margin-right: 28px;
3235
xl: 'mx-xl', // margin-left: 32px; margin-right: 32px;
3336
'2xl': 'mx-2xl', // margin-left: 48px; margin-right: 48px;
3437
},
@@ -39,6 +42,7 @@ export const spacingStyles = cva('', {
3942
md: 'my-md', // margin-top: 16px; margin-bottom: 16px;
4043
ml: 'my-ml', // margin-top: 20px; margin-bottom: 20px;
4144
lg: 'my-lg', // margin-top: 24px; margin-bottom: 24px;
45+
lx: 'my-lx', // margin-top: 28px; margin-bottom: 28px;
4246
xl: 'my-xl', // margin-top: 32px; margin-bottom: 32px;
4347
'2xl': 'my-2xl', // margin-top: 48px; margin-bottom: 48px;
4448
},
@@ -49,6 +53,7 @@ export const spacingStyles = cva('', {
4953
md: 'px-md', // padding-left: 16px; padding-right: 16px;
5054
ml: 'px-ml', // padding-left: 20px; padding-right: 20px;
5155
lg: 'px-lg', // padding-left: 24px; padding-right: 24px;
56+
lx: 'px-lx', // padding-left: 28px; padding-right: 28px;
5257
xl: 'px-xl', // padding-left: 32px; padding-right: 32px;
5358
'2xl': 'px-2xl', // padding-left: 48px; padding-right: 48px;
5459
},
@@ -59,6 +64,7 @@ export const spacingStyles = cva('', {
5964
md: 'py-md', // padding-top: 16px; padding-bottom: 16px;
6065
ml: 'py-ml', // padding-top: 20px; padding-bottom: 20px;
6166
lg: 'py-lg', // padding-top: 24px; padding-bottom: 24px;
67+
lx: 'py-lx', // padding-top: 28px; padding-bottom: 28px;
6268
xl: 'py-xl', // padding-top: 32px; padding-bottom: 32px;
6369
'2xl': 'py-2xl', // padding-top: 48px; padding-bottom: 48px;
6470
},
@@ -69,6 +75,7 @@ export const spacingStyles = cva('', {
6975
md: 'mt-md', // margin-top: 16px;
7076
ml: 'mt-ml', // margin-top: 20px;
7177
lg: 'mt-lg', // margin-top: 24px;
78+
lx: 'mt-lx', // margin-top: 28px;
7279
xl: 'mt-xl', // margin-top: 32px;
7380
'2xl': 'mt-2xl', // margin-top: 48px;
7481
},
@@ -79,6 +86,7 @@ export const spacingStyles = cva('', {
7986
md: 'mb-md', // margin-bottom: 16px;
8087
ml: 'mb-ml', // margin-bottom: 20px;
8188
lg: 'mb-lg', // margin-bottom: 24px;
89+
lx: 'mb-lx', // margin-bottom: 28px;
8290
xl: 'mb-xl', // margin-bottom: 32px;
8391
'2xl': 'mb-2xl', // margin-bottom: 48px;
8492
},
@@ -89,6 +97,7 @@ export const spacingStyles = cva('', {
8997
md: 'ml-md', // margin-left: 16px;
9098
ml: 'ml-ml', // margin-left: 20px;
9199
lg: 'ml-lg', // margin-left: 24px;
100+
lx: 'ml-lx', // margin-left: 28px;
92101
xl: 'ml-xl', // margin-left: 32px;
93102
'2xl': 'ml-2xl', // margin-left: 48px;
94103
},
@@ -99,6 +108,7 @@ export const spacingStyles = cva('', {
99108
md: 'mr-md', // margin-right: 16px;
100109
ml: 'mr-ml', // margin-right: 20px;
101110
lg: 'mr-lg', // margin-right: 24px;
111+
lx: 'mr-lx', // margin-right: 28px;
102112
xl: 'mr-xl', // margin-right: 32px;
103113
'2xl': 'mr-2xl', // margin-right: 48px;
104114
},
@@ -109,6 +119,7 @@ export const spacingStyles = cva('', {
109119
md: 'pt-md', // padding-top: 16px;
110120
ml: 'pt-ml', // padding-top: 20px;
111121
lg: 'pt-lg', // padding-top: 24px;
122+
lx: 'pt-lx', // padding-top: 28px;
112123
xl: 'pt-xl', // padding-top: 32px;
113124
'2xl': 'pt-2xl', // padding-top: 48px;
114125
},
@@ -119,6 +130,7 @@ export const spacingStyles = cva('', {
119130
md: 'pb-md', // padding-bottom: 16px;
120131
ml: 'pb-ml', // padding-bottom: 20px;
121132
lg: 'pb-lg', // padding-bottom: 24px;
133+
lx: 'pb-lx', // padding-bottom: 28px;
122134
xl: 'pb-xl', // padding-bottom: 32px;
123135
'2xl': 'pb-2xl', // padding-bottom: 48px;
124136
},
@@ -129,6 +141,7 @@ export const spacingStyles = cva('', {
129141
md: 'pl-md', // padding-left: 16px;
130142
ml: 'pl-ml', // padding-left: 20px;
131143
lg: 'pl-lg', // padding-left: 24px;
144+
lx: 'pl-lx', // padding-left: 28px;
132145
xl: 'pl-xl', // padding-left: 32px;
133146
'2xl': 'pl-2xl', // padding-left: 48px;
134147
},
@@ -139,6 +152,7 @@ export const spacingStyles = cva('', {
139152
md: 'pr-md', // padding-right: 16px;
140153
ml: 'pr-ml', // padding-right: 20px;
141154
lg: 'pr-lg', // padding-right: 24px;
155+
lx: 'pr-lx', // padding-right: 28px;
142156
xl: 'pr-xl', // padding-right: 32px;
143157
'2xl': 'pr-2xl', // padding-right: 48px;
144158
},

src/shared/ui/button.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ const buttonVariants = cva(
1313
variant: {
1414
default: 'bg-primary text-gray-white active:bg-primary-active disabled:bg-gray-500 disabled:!text-gray-600',
1515
prev: 'bg-gray-100 text-gray-800 active:bg-gray-300 disabled:bg-gray-500 disabled:text-gray-600',
16+
weak: `py-[15px] px-[32px] text-gray-800 flex rounded-sm bg-gray-100 w-full flex items-center justify-center`,
1617
},
1718
size: {
1819
default: 'h-[52px] px-8 rounded-md',
20+
dialog: 'w-[136px] rounded-sm',
1921
},
2022
},
2123
defaultVariants: {

src/shared/ui/dialog/commonDialog.tsx

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Button } from '../button';
2+
3+
import { WrappedDialog } from './wrappedDialog';
4+
5+
import { DialogClose } from '.';
6+
7+
type CommonDialogProps = {
8+
title: string;
9+
trigger: React.ReactNode;
10+
children: React.ReactNode;
11+
onConfirm: () => void;
12+
actionText: string;
13+
cancelText: string;
14+
};
15+
16+
/**
17+
* 공통으로 사용되는 다이얼로그 컴포넌트
18+
* 제목, 내용, 확인/취소 버튼을 포함하는 기본적인 다이얼로그 구조를 제공합니다.
19+
*/
20+
21+
/**
22+
* 공통 다이얼로그 컴포넌트의 Props 타입
23+
* @property {string} title - 다이얼로그 제목
24+
* @property {React.ReactNode} trigger - 다이얼로그를 열기 위한 트리거 요소
25+
* @property {React.ReactNode} children - 다이얼로그 내부 컨텐츠
26+
* @property {() => void} onConfirm - 확인 버튼 클릭 시 실행될 콜백 함수
27+
* @property {string} actionText - 확인 버튼에 표시될 텍스트 (기본값: '확인')
28+
* @property {string} cancelText - 취소 버튼에 표시될 텍스트 (기본값: '취소')
29+
*/
30+
31+
const CommonDialog = ({
32+
title,
33+
trigger,
34+
children,
35+
onConfirm,
36+
actionText = '확인',
37+
cancelText = '취소',
38+
}: CommonDialogProps) => {
39+
return (
40+
<WrappedDialog
41+
title={title}
42+
trigger={trigger}
43+
footer={
44+
<div className="flex gap-2">
45+
<DialogClose asChild>
46+
<Button variant="weak" size="dialog">
47+
{cancelText}
48+
</Button>
49+
</DialogClose>
50+
<DialogClose asChild>
51+
<Button size="dialog" onClick={onConfirm}>
52+
{actionText}
53+
</Button>
54+
</DialogClose>
55+
</div>
56+
}
57+
>
58+
{children}
59+
</WrappedDialog>
60+
);
61+
};
62+
63+
export default CommonDialog;

src/shared/ui/dialog/index.tsx

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
'use client';
2+
3+
import * as DialogPrimitive from '@radix-ui/react-dialog';
4+
// import { X } from 'lucide-react';
5+
import * as React from 'react';
6+
7+
import { cn } from '@/shared/lib/utils';
8+
import { spacingStyles } from '@/shared/spacing';
9+
10+
const Dialog = DialogPrimitive.Root;
11+
12+
const DialogTrigger = DialogPrimitive.Trigger;
13+
14+
const DialogPortal = DialogPrimitive.Portal;
15+
16+
const DialogClose = DialogPrimitive.Close;
17+
18+
const DialogOverlay = React.forwardRef<
19+
React.ElementRef<typeof DialogPrimitive.Overlay>,
20+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
21+
>(({ className, ...props }, ref) => (
22+
<DialogPrimitive.Overlay
23+
ref={ref}
24+
className={cn(
25+
'bg-black/80 fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
26+
className,
27+
)}
28+
{...props}
29+
/>
30+
));
31+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
32+
33+
const DialogContent = React.forwardRef<
34+
React.ElementRef<typeof DialogPrimitive.Content>,
35+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
36+
>(({ className, children, ...props }, ref) => (
37+
<DialogPortal>
38+
<DialogOverlay />
39+
<DialogPrimitive.Content
40+
ref={ref}
41+
className={cn(
42+
'rounded-ml fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
43+
spacingStyles({ paddingTop: 'xl', paddingX: 'ml', paddingBottom: 'ml' }),
44+
className,
45+
)}
46+
{...props}
47+
>
48+
{children}
49+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-zinc-100 data-[state=open]:text-zinc-500 dark:ring-offset-zinc-950 dark:focus:ring-zinc-300 dark:data-[state=open]:bg-zinc-800 dark:data-[state=open]:text-zinc-400">
50+
{/* <X className="h-4 w-4" /> */}
51+
{/* <span className="sr-only">Close</span> */}
52+
</DialogPrimitive.Close>
53+
</DialogPrimitive.Content>
54+
</DialogPortal>
55+
));
56+
DialogContent.displayName = DialogPrimitive.Content.displayName;
57+
58+
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
59+
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
60+
);
61+
DialogHeader.displayName = 'DialogHeader';
62+
63+
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
64+
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
65+
);
66+
DialogFooter.displayName = 'DialogFooter';
67+
68+
const DialogTitle = React.forwardRef<
69+
React.ElementRef<typeof DialogPrimitive.Title>,
70+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
71+
>(({ className, ...props }, ref) => (
72+
<DialogPrimitive.Title
73+
ref={ref}
74+
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
75+
{...props}
76+
/>
77+
));
78+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
79+
80+
const DialogDescription = React.forwardRef<
81+
React.ElementRef<typeof DialogPrimitive.Description>,
82+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
83+
>(({ className, ...props }, ref) => (
84+
<DialogPrimitive.Description
85+
ref={ref}
86+
className={cn('text-sm text-zinc-500 dark:text-zinc-400', className)}
87+
{...props}
88+
/>
89+
));
90+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
91+
92+
export {
93+
Dialog,
94+
DialogPortal,
95+
DialogOverlay,
96+
DialogTrigger,
97+
DialogClose,
98+
DialogContent,
99+
DialogHeader,
100+
DialogFooter,
101+
DialogTitle,
102+
DialogDescription,
103+
};

0 commit comments

Comments
 (0)