Skip to content

Commit be6443b

Browse files
authored
[#50] ✨ 공통 컴포넌트 dropdown 개발 (#111)
* [#50] 🚚 rename index.tsx -> index.ts * [#67] 💄 solve prettier build warnings by formatting * [#97] ✨ add dropdown component * [#97] ♻️ add close method to useToggle hook * [#97] ✅ add dropdown stories * [#97] 🔧 add icon assets (setting / workbag / codefile / logout) * [#97] 💄 adjust svg fill and stroke attributes * [#97] 💄 replace size props from 44 px to 48px in avatar * [#97] 💄 expand highlight component with new className props * [#97] ✨ add header user menu component * [#97] ✅ add header user menu stories * [#97] 🐛 remove unused value props from dropdown items * [#97] 🚚 remove comments and allow close method when touching outside of dropdown * [#97] 🚚 remove closeOnSelect props from header user menu component * [#97] 🗑️ remove select component * [#97] 🐛 solve build warning by removing unused import line * [#97] ✨ make sure dropdown to close when clicked outside of the menu
1 parent d9684b9 commit be6443b

File tree

21 files changed

+525
-11
lines changed

21 files changed

+525
-11
lines changed

src/app/providers.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
QueryClientProvider,
88
isServer,
99
} from '@tanstack/react-query'
10-
import { ReactNode } from 'react'
1110

1211
// In Next.js, this file would be called: app/providers.tsx
1312

@@ -38,7 +37,11 @@ function getQueryClient() {
3837
return browserQueryClient
3938
}
4039

41-
const Providers = ({ children }: { children: ReactNode }): JSX.Element => {
40+
const Providers = ({
41+
children,
42+
}: {
43+
children: React.ReactNode
44+
}): JSX.Element => {
4245
// NOTE: Avoid useState when initializing the query client if you don't
4346
// have a suspense boundary between this and the code that may
4447
// suspend because React will throw away the client on the initial

src/assets/IconList.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import IcCheckboxOn from './icons/ic-checkbox-on.svg'
1212
import IcChevronLeft from './icons/ic-chevron-left.svg'
1313
import IcChevronRight from './icons/ic-chevron-right.svg'
1414
import IcClose from './icons/ic-close.svg'
15+
import IcCodefile from './icons/ic-codefile.svg'
1516
import IcComment from './icons/ic-comment.svg'
1617
import IcEdit from './icons/ic-edit.svg'
1718
import IcEditorAlignCenter from './icons/ic-editor-align-center.svg'
@@ -30,17 +31,24 @@ import IcEyeClosed from './icons/ic-eye-closed.svg'
3031
import IcEyeOpen from './icons/ic-eye-open.svg'
3132
import IcHeart from './icons/ic-heart.svg'
3233
import IcKebabMenu from './icons/ic-kebab-menu.svg'
34+
import IcLogout from './icons/ic-logout.svg'
3335
import IcMemberAdd from './icons/ic-member-add.svg'
3436
import IcMemberDelete from './icons/ic-member-delete.svg'
3537
import IcPaperPlane from './icons/ic-paper-plane.svg'
3638
import IcPlus from './icons/ic-plus.svg'
3739
import IcSearch from './icons/ic-search.svg'
40+
import IcSetting from './icons/ic-setting.svg'
3841
import IcShare from './icons/ic-share.svg'
3942
import IcStart from './icons/ic-start.svg'
43+
import IcWorkbag from './icons/ic-workbag.svg'
4044

4145
export {
4246
IcArrowRight,
47+
IcLogout,
4348
IcAvatar,
49+
IcCodefile,
50+
IcWorkbag,
51+
IcSetting,
4452
IcBin,
4553
IcCalendar,
4654
IcCaretDown,

src/assets/icons/ic-codefile.svg

Lines changed: 5 additions & 0 deletions
Loading

src/assets/icons/ic-logout.svg

Lines changed: 4 additions & 0 deletions
Loading

src/assets/icons/ic-setting.svg

Lines changed: 5 additions & 0 deletions
Loading

src/assets/icons/ic-workbag.svg

Lines changed: 3 additions & 0 deletions
Loading

src/components/common/avatar/Avatar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const baseStyle =
1717

1818
const styleBySize: Record<AvatarSize, string> = {
1919
24: 'w-24 h-24',
20-
48: 'w-44 h-44',
20+
48: 'w-48 h-48',
2121
60: 'w-60 h-60',
2222
180: 'w-180 h-180',
2323
}

src/components/common/button/Clickable.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import React from 'react'
2+
13
import '@testing-library/jest-dom'
24
import { render, screen } from '@testing-library/react'
3-
import React from 'react'
45

56
import { Clickable, ClickableProps } from './Clickable'
67

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import React from 'react'
2+
3+
import '@testing-library/jest-dom'
4+
import { fireEvent, render, screen } from '@testing-library/react'
5+
6+
import { Dropdown } from './Dropdown'
7+
8+
describe('Dropdown Component', () => {
9+
it('renders children correctly', () => {
10+
render(
11+
<Dropdown>
12+
<Dropdown.Trigger>Open Menu</Dropdown.Trigger>
13+
<Dropdown.Menu>
14+
<Dropdown.Item>Item 1</Dropdown.Item>
15+
<Dropdown.Item>Item 2</Dropdown.Item>
16+
</Dropdown.Menu>
17+
</Dropdown>
18+
)
19+
20+
// Trigger 버튼이 렌더링되었는지 확인
21+
expect(screen.getByText('Open Menu')).toBeInTheDocument()
22+
23+
// Menu의 아이템이 초기에는 보이지 않아야 함
24+
expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
25+
expect(screen.queryByText('Item 2')).not.toBeInTheDocument()
26+
})
27+
28+
it('opens the menu when the trigger is clicked', () => {
29+
render(
30+
<Dropdown>
31+
<Dropdown.Trigger>Open Menu</Dropdown.Trigger>
32+
<Dropdown.Menu>
33+
<Dropdown.Item>Item 1</Dropdown.Item>
34+
<Dropdown.Item>Item 2</Dropdown.Item>
35+
</Dropdown.Menu>
36+
</Dropdown>
37+
)
38+
39+
const trigger = screen.getByText('Open Menu')
40+
41+
// Trigger 버튼 클릭
42+
fireEvent.click(trigger)
43+
44+
// Menu의 아이템이 보여야 함
45+
expect(screen.getByText('Item 1')).toBeInTheDocument()
46+
expect(screen.getByText('Item 2')).toBeInTheDocument()
47+
})
48+
49+
it('closes the menu when an item is clicked', () => {
50+
render(
51+
<Dropdown>
52+
<Dropdown.Trigger>Open Menu</Dropdown.Trigger>
53+
<Dropdown.Menu>
54+
<Dropdown.Item>Item 1</Dropdown.Item>
55+
<Dropdown.Item>Item 2</Dropdown.Item>
56+
</Dropdown.Menu>
57+
</Dropdown>
58+
)
59+
60+
const trigger = screen.getByText('Open Menu')
61+
62+
// Trigger 버튼 클릭
63+
fireEvent.click(trigger)
64+
65+
// Menu의 아이템이 보여야 함
66+
const item = screen.getByText('Item 1')
67+
fireEvent.click(item)
68+
69+
// Menu가 닫혀야 함
70+
expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
71+
expect(screen.queryByText('Item 2')).not.toBeInTheDocument()
72+
})
73+
74+
it('does not close the menu when closeOnSelect is false', () => {
75+
render(
76+
<Dropdown>
77+
<Dropdown.Trigger>Open Menu</Dropdown.Trigger>
78+
<Dropdown.Menu>
79+
<Dropdown.Item closeOnSelect={false}>Item 1</Dropdown.Item>
80+
<Dropdown.Item>Item 2</Dropdown.Item>
81+
</Dropdown.Menu>
82+
</Dropdown>
83+
)
84+
85+
const trigger = screen.getByText('Open Menu')
86+
87+
// Trigger 버튼 클릭
88+
fireEvent.click(trigger)
89+
90+
// Menu의 아이템이 보여야 함
91+
const item = screen.getByText('Item 1')
92+
fireEvent.click(item)
93+
94+
// Menu가 닫히지 않아야 함
95+
expect(screen.getByText('Item 1')).toBeInTheDocument()
96+
expect(screen.getByText('Item 2')).toBeInTheDocument()
97+
})
98+
99+
it('closes the menu when clicking outside the dropdown', () => {
100+
render(
101+
<>
102+
<Dropdown>
103+
<Dropdown.Trigger>Open Menu</Dropdown.Trigger>
104+
<Dropdown.Menu>
105+
<Dropdown.Item>Item 1</Dropdown.Item>
106+
<Dropdown.Item>Item 2</Dropdown.Item>
107+
</Dropdown.Menu>
108+
</Dropdown>
109+
<button>Outside Button</button>
110+
</>
111+
)
112+
113+
const trigger = screen.getByText('Open Menu')
114+
const outsideButton = screen.getByText('Outside Button')
115+
116+
// Trigger 버튼 클릭
117+
fireEvent.click(trigger)
118+
119+
// Menu의 아이템이 보여야 함
120+
expect(screen.getByText('Item 1')).toBeInTheDocument()
121+
expect(screen.getByText('Item 2')).toBeInTheDocument()
122+
123+
// Dropdown 외부 클릭
124+
fireEvent.click(outsideButton)
125+
126+
// Menu가 닫혀야 함
127+
expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
128+
expect(screen.queryByText('Item 2')).not.toBeInTheDocument()
129+
})
130+
})
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { createContext, useContext, useEffect, useRef, useState } from 'react'
2+
3+
import clsx from 'clsx'
4+
import { twMerge } from 'tailwind-merge'
5+
6+
import { Box } from '@/components/common/containers'
7+
8+
type BaseProps = React.HTMLAttributes<HTMLElement>
9+
10+
interface DropdownContextType {
11+
isOpen: boolean
12+
toggle: () => void
13+
close: () => void
14+
}
15+
16+
const DropdownContext = createContext<DropdownContextType | null>(null)
17+
18+
const useDropdownContext = () => {
19+
const context = useContext(DropdownContext)
20+
if (!context) {
21+
throw new Error('useDropdownContext must be used within a DropdownProvider')
22+
}
23+
return context
24+
}
25+
26+
const Dropdown = ({ children, className }: BaseProps): JSX.Element => {
27+
const [isOpen, setIsOpen] = useState(false)
28+
const dropdownRef = useRef<HTMLDivElement>(null)
29+
30+
const toggle = () => setIsOpen(!isOpen)
31+
const close = () => setIsOpen(false)
32+
33+
const dropdownClass = twMerge('relative', className)
34+
35+
const handleClickOutside = (event: MouseEvent) => {
36+
if (
37+
dropdownRef.current &&
38+
!dropdownRef.current.contains(event.target as Node)
39+
) {
40+
close()
41+
}
42+
}
43+
44+
useEffect(() => {
45+
document.addEventListener('mousedown', handleClickOutside)
46+
return () => {
47+
document.removeEventListener('mousedown', handleClickOutside)
48+
}
49+
}, [])
50+
51+
return (
52+
<DropdownContext.Provider value={{ isOpen, toggle, close }}>
53+
<div ref={dropdownRef} className={dropdownClass} tabIndex={-1}>
54+
{children}
55+
</div>
56+
</DropdownContext.Provider>
57+
)
58+
}
59+
60+
const Trigger = ({ children, className }: BaseProps) => {
61+
const { toggle } = useDropdownContext()
62+
63+
return (
64+
<button
65+
type='button'
66+
className={className}
67+
onClick={toggle}
68+
aria-haspopup='listbox'
69+
aria-expanded={true}
70+
>
71+
{children}
72+
</button>
73+
)
74+
}
75+
76+
interface MenuProps extends BaseProps {
77+
position?: 'dropup' | 'dropdown'
78+
alignment?: 'left' | 'right'
79+
}
80+
81+
const getMenuStyle = (position: string, alignment: string, className: string) =>
82+
twMerge(
83+
'absolute z-10 w-216 flex-col items-start p-8 shadow-level4',
84+
position === 'dropdown' ? 'mt-4' : 'bottom-full mb-4',
85+
alignment === 'right' ? 'right-0' : 'left-0',
86+
className
87+
)
88+
89+
const Menu = ({
90+
children,
91+
className = '',
92+
position = 'dropdown',
93+
alignment = 'left',
94+
}: MenuProps): JSX.Element | null => {
95+
const { isOpen } = useDropdownContext()
96+
if (!isOpen) return null
97+
98+
return (
99+
<Box
100+
role='listbox'
101+
variant='contained'
102+
className={getMenuStyle(position, alignment, className)}
103+
>
104+
{children}
105+
</Box>
106+
)
107+
}
108+
109+
interface ItemProps extends BaseProps {
110+
closeOnSelect?: boolean
111+
}
112+
113+
const getItemStyle = (className: string) =>
114+
clsx(
115+
'flex h-40 w-full items-center rounded-8 px-12 text-body2 text-gray-800 hover:bg-gray-100',
116+
className
117+
)
118+
119+
const Item = ({
120+
children,
121+
className = '',
122+
closeOnSelect = true,
123+
onClick,
124+
...props
125+
}: ItemProps) => {
126+
const { close } = useDropdownContext()
127+
128+
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
129+
if (typeof onClick === 'function') {
130+
onClick(e)
131+
}
132+
if (closeOnSelect) {
133+
close()
134+
}
135+
}
136+
137+
return (
138+
<button
139+
className={getItemStyle(className)}
140+
type='button'
141+
onClick={handleClick}
142+
{...props}
143+
>
144+
{children}
145+
</button>
146+
)
147+
}
148+
149+
Dropdown.Trigger = Trigger
150+
Dropdown.Menu = Menu
151+
Dropdown.Item = Item
152+
153+
export { Dropdown }

0 commit comments

Comments
 (0)