Skip to content

Commit 095c915

Browse files
[feat] Select 컴포넌트 생성
[feat] Select 컴포넌트 생성
2 parents eccdd2e + e5f9b41 commit 095c915

File tree

3 files changed

+224
-10
lines changed

3 files changed

+224
-10
lines changed

src/components/Button.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React from "react";
22

3-
import clsx from "clsx";
4-
import { twMerge } from "tailwind-merge";
3+
import { cn } from "@/utils/cn";
54

65
type ButtonVariant = "primary" | "white";
76
type ButtonTextSize = "lg" | "md" | "sm";
@@ -38,14 +37,12 @@ export default function Button({
3837
}: ButtonProps) {
3938
const baseClasses = "rounded-md";
4039

41-
const mergedClasses = twMerge(
42-
clsx(
43-
baseClasses,
44-
textSizeClassMap[textSize],
45-
disabled ? disabledClass : variantClassMap[variant],
46-
fullWidth && "w-full",
47-
className,
48-
),
40+
const mergedClasses = cn(
41+
baseClasses,
42+
textSizeClassMap[textSize],
43+
disabled ? disabledClass : variantClassMap[variant],
44+
fullWidth && "w-full",
45+
className,
4946
);
5047

5148
return (

src/components/Select.tsx

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import {
2+
useState,
3+
useEffect,
4+
useRef,
5+
ReactNode,
6+
SelectHTMLAttributes,
7+
useMemo,
8+
} from "react";
9+
10+
import { DropdownDown, DropdownUp } from "@/assets/icon";
11+
import { cn } from "@/utils/cn";
12+
13+
const sizeMap = {
14+
lg: "py-4 px-5 text-[1rem]",
15+
sm: "p-2.5 text-sm",
16+
} as const;
17+
18+
interface Option {
19+
label: string;
20+
value: string;
21+
}
22+
23+
interface SelectProps
24+
extends Omit<
25+
SelectHTMLAttributes<HTMLButtonElement>,
26+
"size" | "onChange" | "disabled"
27+
> {
28+
id?: string;
29+
label?: string;
30+
options: Option[];
31+
value?: string;
32+
onValueChange?: (value: string) => void;
33+
placeholder?: string;
34+
size?: keyof typeof sizeMap;
35+
fullWidth?: boolean;
36+
className?: string;
37+
wrapperClassName?: string;
38+
}
39+
40+
// 라벨과 입력 영역을 감싸는 공통 컴포넌트
41+
function Field({
42+
id,
43+
label,
44+
children,
45+
}: {
46+
id?: string;
47+
label?: string;
48+
children: ReactNode;
49+
}) {
50+
return (
51+
<div>
52+
{label && (
53+
<label htmlFor={id} className="inline-block mb-2 leading-[1.625rem]">
54+
{label}
55+
</label>
56+
)}
57+
{children}
58+
</div>
59+
);
60+
}
61+
62+
function Select({
63+
id,
64+
label,
65+
options,
66+
value,
67+
onValueChange,
68+
placeholder = "선택",
69+
size = "lg",
70+
fullWidth,
71+
className,
72+
wrapperClassName,
73+
...rest
74+
}: SelectProps) {
75+
const [open, setOpen] = useState(false);
76+
const [buttonWidth, setButtonWidth] = useState<number>(0);
77+
78+
const wrapperRef = useRef<HTMLUListElement>(null);
79+
const buttonRef = useRef<HTMLButtonElement>(null);
80+
81+
const selectedOption = useMemo(() => {
82+
return options.find((option) => option.value === value);
83+
}, [options, value]);
84+
85+
const wrapperClassNames = cn(
86+
"relative",
87+
{
88+
"w-full": fullWidth,
89+
},
90+
wrapperClassName,
91+
);
92+
93+
const buttonClassNames = cn(
94+
"flex items-center justify-between rounded-[0.375rem] cursor-pointer",
95+
{
96+
"w-full": fullWidth,
97+
"bg-white border border-gray-30": size === "lg",
98+
"bg-gray-10 font-bold": size === "sm",
99+
},
100+
value ? "text-black" : "text-gray-40",
101+
sizeMap[size],
102+
className,
103+
);
104+
105+
const listClassNames = cn(
106+
"absolute top-full left-0 mt-1 border rounded-[0.375rem] bg-white border-gray-30 text-black shadow-lg z-10 max-h-48 overflow-y-auto",
107+
);
108+
109+
const handleSelect = (selectedValue: string) => {
110+
onValueChange?.(selectedValue);
111+
setOpen(false);
112+
};
113+
114+
// 드롭다운 외부 클릭 시 닫기
115+
useEffect(() => {
116+
const handleClickOutside = (event: MouseEvent) => {
117+
const target = event.target as Node;
118+
if (
119+
!buttonRef.current?.contains(target) &&
120+
!wrapperRef.current?.contains(target)
121+
) {
122+
setOpen(false);
123+
}
124+
};
125+
126+
document.addEventListener("mousedown", handleClickOutside);
127+
return () => {
128+
document.removeEventListener("mousedown", handleClickOutside);
129+
};
130+
}, []);
131+
132+
// 버튼 너비 측정 (드롭다운 너비 일치시키기 위함)
133+
useEffect(() => {
134+
if (buttonRef.current) {
135+
const rect = buttonRef.current.getBoundingClientRect();
136+
setButtonWidth(rect.width);
137+
}
138+
}, [open, fullWidth, size, value]); // 버튼 사이즈가 변할 수 있는 경우
139+
140+
return (
141+
<Field id={id} label={label}>
142+
<div className={wrapperClassNames}>
143+
<button
144+
id={id}
145+
type="button"
146+
ref={buttonRef}
147+
onClick={() => setOpen((prev) => !prev)}
148+
className={buttonClassNames}
149+
{...rest}
150+
>
151+
{selectedOption?.label || placeholder}
152+
{open ? (
153+
<DropdownUp className="ml-2" />
154+
) : (
155+
<DropdownDown className="ml-2" />
156+
)}
157+
</button>
158+
159+
{open && (
160+
<ul
161+
ref={wrapperRef}
162+
className={listClassNames}
163+
style={{
164+
width: buttonWidth,
165+
}}
166+
>
167+
{options.map((option) => (
168+
<li
169+
key={option.value}
170+
className="border-b border-gray-20 last:border-0"
171+
>
172+
<button
173+
type="button"
174+
className={cn(
175+
"w-full text-center hover:bg-gray-10 cursor-pointer",
176+
size === "sm"
177+
? "px-3 py-2 text-sm"
178+
: "px-5 py-3 text-[1rem]",
179+
)}
180+
onClick={() => handleSelect(option.value)}
181+
>
182+
{option.label}
183+
</button>
184+
</li>
185+
))}
186+
</ul>
187+
)}
188+
</div>
189+
</Field>
190+
);
191+
}
192+
193+
export default Select;

src/index.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,27 @@
22
@import "tailwindcss";
33
@import "./styles/base.css";
44
@import "./styles/utilities.css";
5+
6+
::-webkit-scrollbar {
7+
width: 12px;
8+
}
9+
10+
::-webkit-scrollbar-thumb {
11+
background-color: #7d7986;
12+
border: 4px solid transparent;
13+
background-clip: padding-box;
14+
border-radius: 9999px;
15+
min-height: 60px;
16+
}
17+
18+
::-webkit-scrollbar-thumb:hover {
19+
background-color: #636169;
20+
}
21+
22+
::-webkit-scrollbar-track {
23+
background: transparent;
24+
}
25+
26+
::-webkit-scrollbar-button {
27+
display: none;
28+
}

0 commit comments

Comments
 (0)