Skip to content

Commit af032c5

Browse files
committed
feat: Select 컴포넌트 생성
1 parent 26b3806 commit af032c5

File tree

3 files changed

+223
-10
lines changed

3 files changed

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