Skip to content

Commit bcde297

Browse files
authored
[#233] ✨ 멤버 설렉트 컴포넌트 + 팀 멤버 추가 모달 (#240)
* [#233] 💄 add shadow none * [#233] ✨ member select * [#233] ✅ member select story * [#233] ✨ add member form * [#233] ✨ allign add member form with modal
1 parent 4f173bd commit bcde297

File tree

9 files changed

+397
-4
lines changed

9 files changed

+397
-4
lines changed

src/app/(pages)/team/[id]/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import { Box, Container } from '@/components/common/containers'
2222
import { Divider } from '@/components/common/divider'
2323
import { Highlight, Text } from '@/components/common/text'
2424
import { ContentViewer } from '@/components/shared/contentViewer'
25+
import { AddTeamMemberModalContent } from '@/components/team/AddTeamMemberModalContent'
26+
27+
import useModalStore from '@/stores/useModalStore'
2528

2629
const teamTypeMap: Record<TeamType, string> = {
2730
STUDY: '스터디',
@@ -71,7 +74,7 @@ export default function TeamDetailPage(): JSX.Element {
7174
likes,
7275
createdAt,
7376
} = data
74-
77+
const { openModal } = useModalStore()
7578
return (
7679
<Container className='mx-auto my-80 flex flex-col gap-20'>
7780
<section className='flex w-full flex-col gap-12'>
@@ -211,6 +214,7 @@ export default function TeamDetailPage(): JSX.Element {
211214
borderColor='gray'
212215
textColor='gray800'
213216
className='rounded-4'
217+
onClick={() => openModal(<AddTeamMemberModalContent />)}
214218
>
215219
<IcPeoplePlus width={24} height={24} />
216220
멤버 등록

src/components/common/dropdown/Dropdown.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { createContext, useContext, useEffect, useRef, useState } from 'react'
44

5-
import clsx from 'clsx'
5+
import { cn } from '@/lib/utils'
66
import { twMerge } from 'tailwind-merge'
77

88
import { Box } from '@/components/common/containers'
@@ -114,7 +114,7 @@ interface ItemProps extends BaseProps {
114114
}
115115

116116
const getItemStyle = (className: string) =>
117-
clsx(
117+
cn(
118118
'flex h-40 w-full items-center rounded-8 px-12 text-body2 font-medium text-gray-800 hover:bg-gray-100',
119119
className
120120
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Controller, useForm } from 'react-hook-form'
2+
3+
import { Label } from '@/components/common/label'
4+
5+
import { MemberSelect } from '../select/MemberSelect'
6+
import { Form } from './Form'
7+
8+
const mockMembers = [
9+
{
10+
id: 1,
11+
nickname: '망곰이',
12+
imageUrl: 'https://avatars.githubusercontent.com/u/1234567?v=4',
13+
},
14+
{
15+
id: 2,
16+
nickname: '망곰쓰',
17+
imageUrl: 'https://avatars.githubusercontent.com/u/2345678?v=4',
18+
},
19+
{
20+
id: 3,
21+
nickname: '망곰곰곰맨',
22+
imageUrl: 'https://avatars.githubusercontent.com/u/2345678?v=4',
23+
},
24+
{
25+
id: 4,
26+
nickname: '망곰곰곰맨',
27+
imageUrl: 'https://avatars.githubusercontent.com/u/2345678?v=4',
28+
},
29+
{
30+
id: 5,
31+
nickname: '망곰곰곰맨',
32+
imageUrl: 'https://avatars.githubusercontent.com/u/2345678?v=4',
33+
},
34+
{
35+
id: 6,
36+
nickname: '망곰곰곰맨',
37+
imageUrl: 'https://avatars.githubusercontent.com/u/2345678?v=4',
38+
},
39+
]
40+
export const AddTeamMemberForm = (): JSX.Element => {
41+
const methods = useForm()
42+
const { handleSubmit, control, watch } = methods
43+
const values = watch()
44+
const onSubmit = (data: any) => console.log(data)
45+
46+
return (
47+
<Form
48+
id='addTeamMember'
49+
methods={methods}
50+
onSubmit={handleSubmit(onSubmit)}
51+
className='w-full'
52+
>
53+
<div className='flex flex-col gap-4'>
54+
<Label labelText='닉네임' />
55+
<Controller
56+
name={'nickname'}
57+
control={control}
58+
rules={{ required: '기술 스택을 선택해주세요.' }}
59+
render={({ field: { onChange } }) => (
60+
<MemberSelect
61+
members={[]}
62+
onSelect={value => onChange(value)}
63+
selectedMembers={values.nickname}
64+
/>
65+
)}
66+
/>
67+
</div>
68+
</Form>
69+
)
70+
}

src/components/shared/modalContent/ModalContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const ModalContent = ({
1616
children,
1717
}: React.PropsWithChildren): JSX.Element => {
1818
return (
19-
<div className={'flex flex-col items-center gap-20 text-center'}>
19+
<div className={'flex w-full flex-col items-center gap-20 text-center'}>
2020
{children}
2121
</div>
2222
)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
'use client'
2+
3+
import Image from 'next/image'
4+
5+
import { useState } from 'react'
6+
7+
import { IcCaretDown, IcCaretUp, IcCheck, IcSearch } from '@/assets/IconList'
8+
import { cn } from '@/lib/utils'
9+
10+
import { Box } from '@/components/common/containers'
11+
import { Dropdown, useDropdownContext } from '@/components/common/dropdown'
12+
import { TextInput } from '@/components/common/input'
13+
import { Text } from '@/components/common/text'
14+
15+
interface MemberSelectProps {
16+
members: MemberInfo[]
17+
selectedMembers: MemberInfo[]
18+
onSelect: (value: MemberInfo[]) => void
19+
placeholder?: string
20+
className?: string
21+
}
22+
23+
const MemberSelectTrigger = ({
24+
searchTerm,
25+
setSearchTerm,
26+
placeholder,
27+
}: {
28+
searchTerm: string
29+
setSearchTerm: (term: string) => void
30+
placeholder: string
31+
}) => {
32+
const { isOpen, toggle } = useDropdownContext()
33+
34+
return (
35+
<Dropdown.Trigger className='w-full'>
36+
<TextInput
37+
fullWidth
38+
value={searchTerm}
39+
onChange={e => {
40+
setSearchTerm(e.target.value.trim())
41+
if (!isOpen) {
42+
toggle()
43+
}
44+
}}
45+
placeholder={placeholder}
46+
className={cn(
47+
'h-48 p-12 focus:border-gray-200 focus:outline-none',
48+
isOpen && 'rounded-b-0'
49+
)}
50+
/>
51+
</Dropdown.Trigger>
52+
)
53+
}
54+
55+
export const MemberSelect = ({
56+
members,
57+
selectedMembers,
58+
onSelect,
59+
placeholder = '멤버 선택',
60+
}: MemberSelectProps): JSX.Element => {
61+
const [searchTerm, setSearchTerm] = useState<string>('')
62+
63+
const filteredMembers = members
64+
.filter((member: MemberInfo) =>
65+
member.nickname.toLowerCase().includes(searchTerm.trim().toLowerCase())
66+
)
67+
.slice(0, 5)
68+
69+
const toggleValue = (value: MemberInfo) => {
70+
if (selectedMembers === null) return
71+
72+
if (selectedMembers.includes(value)) {
73+
onSelect(selectedMembers.filter(v => v !== value))
74+
} else {
75+
onSelect([...selectedMembers, value])
76+
}
77+
}
78+
79+
return (
80+
<Dropdown>
81+
<MemberSelectTrigger
82+
searchTerm={searchTerm}
83+
setSearchTerm={setSearchTerm}
84+
placeholder={placeholder}
85+
/>
86+
87+
<Dropdown.Menu className='shadow-none static mt-0 w-full gap-8 rounded-t-0 border-x-1 border-b-1 border-solid border-gray-200 p-12'>
88+
{filteredMembers.map((member: MemberInfo) => {
89+
const isSelected = selectedMembers.find(
90+
selectedMember => selectedMember.id === member.id
91+
)
92+
const regex = searchTerm ? new RegExp(`(${searchTerm})`, 'gi') : null
93+
const parts = regex ? member.nickname.split(regex) : [member.nickname]
94+
return (
95+
<Dropdown.Item
96+
closeOnSelect={false}
97+
key={member.id}
98+
onClick={() => toggleValue(member)}
99+
className='flex h-48 w-full items-center gap-10 rounded-8 px-0 hover:bg-common-white hover:font-bold'
100+
>
101+
<Image
102+
src={member.imageUrl || '/default-profile.png'}
103+
alt='profile'
104+
width={24}
105+
height={24}
106+
className='rounded-full'
107+
/>
108+
<Text.Body
109+
variant='body2'
110+
className={cn('text-gray-800', {
111+
'font-bold text-primary-normal': isSelected,
112+
})}
113+
>
114+
{parts.map((part, index) =>
115+
regex && regex.test(part) ? (
116+
<span key={index} className='font-bold'>
117+
{part}
118+
</span>
119+
) : (
120+
part
121+
)
122+
)}
123+
</Text.Body>
124+
<IcCheck
125+
className={cn('ml-auto', { 'text-primary-normal': isSelected })}
126+
width={24}
127+
height={24}
128+
/>
129+
</Dropdown.Item>
130+
)
131+
})}
132+
{filteredMembers.length === 0 && (
133+
<Text.Body
134+
variant='body2'
135+
color='gray500'
136+
className='py-12 text-center'
137+
>
138+
검색 결과가 없습니다
139+
</Text.Body>
140+
)}
141+
</Dropdown.Menu>
142+
</Dropdown>
143+
)
144+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ModalContent } from '@/components/shared/modalContent'
2+
3+
import { AddTeamMemberForm } from '../shared/form/AddTeamMemberForm'
4+
5+
export const AddTeamMemberModalContent = (): JSX.Element => {
6+
return (
7+
<ModalContent>
8+
<ModalContent.Header
9+
title={'멤버 등록'}
10+
subTitle={'추가할 멤버의 닉네임을 작성해주세요!'}
11+
/>
12+
<AddTeamMemberForm />
13+
<ModalContent.Button form='addTeamMember' type='submit'>
14+
등록
15+
</ModalContent.Button>
16+
</ModalContent>
17+
)
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Meta, StoryObj } from '@storybook/react'
2+
3+
import { AddTeamMemberModalContent } from '@/components/team/AddTeamMemberModalContent'
4+
5+
const meta: Meta<typeof AddTeamMemberModalContent> = {
6+
title: 'Components/Modal/AddTeamMemberModalContent',
7+
component: AddTeamMemberModalContent,
8+
parameters: {
9+
layout: 'centered', // 모달 중앙 배치
10+
},
11+
}
12+
13+
export default meta
14+
type Story = StoryObj<typeof AddTeamMemberModalContent>
15+
16+
export const Default: Story = {
17+
args: {},
18+
}

0 commit comments

Comments
 (0)