diff --git a/.eslintrc.js b/.eslintrc.js
index c339e98..45bbd3f 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -34,7 +34,7 @@ module.exports = {
'error',
{ allowSameFolder: true, rootDir: 'src', prefix: '@' },
],
-
+ 'prettier/prettier': 'off',
// Typescript
'@typescript-eslint/no-explicit-any': 'error', // any 사용 금지
'@typescript-eslint/prefer-nullish-coalescing': 'warn', // ?? 연산자 사용 권장
diff --git a/src/components/layout/container/container.tsx b/src/components/layout/container/container.tsx
index b79a9dc..cdb134d 100644
--- a/src/components/layout/container/container.tsx
+++ b/src/components/layout/container/container.tsx
@@ -5,21 +5,19 @@ interface Props {
as?: ElementType;
className?: string;
children: ReactNode;
- grow?: boolean;
}
export const Wrapper = ({ children }: { children: ReactNode }) => {
return
{children}
;
};
-const Container = ({ as: Component = 'main', grow = false, className, children }: Props) => {
+const Container = ({ as: Component = 'div', className, children }: Props) => {
return (
diff --git a/src/components/ui/dropdown/dropdown.stories.tsx b/src/components/ui/dropdown/dropdown.stories.tsx
new file mode 100644
index 0000000..baefeb1
--- /dev/null
+++ b/src/components/ui/dropdown/dropdown.stories.tsx
@@ -0,0 +1,49 @@
+import { ADDRESS_CODE, CATEGORY_CODE, SORT_CODE } from '@/constants/dropdown';
+import type { Meta, StoryObj } from '@storybook/nextjs';
+import Dropdown from './dropdown';
+const OPTIONS_MAP = {
+ CATEGORY_CODE,
+ ADDRESS_CODE,
+ SORT_CODE,
+};
+const meta: Meta = {
+ title: 'UI/Dropdown',
+ component: Dropdown,
+ tags: ['autodocs'],
+ args: {
+ name: 'status',
+ label: '카테고리',
+ placeholder: '카테고리를 선택하세요',
+ values: CATEGORY_CODE, // 기본값
+ },
+ argTypes: {
+ values: {
+ control: 'select',
+ options: Object.keys(OPTIONS_MAP),
+ mapping: OPTIONS_MAP,
+ },
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Medium: Story = {
+ args: {
+ values: ADDRESS_CODE,
+ className: 'w-full',
+ },
+};
+
+export const Small: Story = {
+ args: {
+ size: 'sm',
+ name: 'status-sm',
+ },
+};
+
+export const WithDefaultValue: Story = {
+ args: {
+ defaultValue: CATEGORY_CODE[1],
+ },
+};
diff --git a/src/components/ui/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx
new file mode 100644
index 0000000..fe27c6f
--- /dev/null
+++ b/src/components/ui/dropdown/dropdown.tsx
@@ -0,0 +1,110 @@
+import { Icon } from '@/components/ui/icon';
+import useClickOutside from '@/hooks/useClickOutside';
+import useToggle from '@/hooks/useToggle';
+import { cn } from '@/lib/utils/cn';
+import { useRef, useState } from 'react';
+const DROPDOWN_STYLE = {
+ base: 'flex-1 text-left min-w-[110px]',
+ md: 'base-input !pr-10',
+ sm: 'rounded-md bg-gray-100 py-1.5 pl-3 pr-7 text-body-s font-bold',
+} as const;
+const DROPDOWN_ICON_STYLE = {
+ base: 'absolute top-1/2 -translate-y-1/2',
+ md: 'right-5',
+ sm: 'right-3',
+} as const;
+
+const DROPDOWN_ITEM_STYLE = {
+ base: 'border-b-[1px] last:border-b-0 block w-full whitespace-nowrap border-gray-200 px-5 text-body-s hover:bg-gray-50',
+ md: 'py-3',
+ sm: 'py-2',
+} as const;
+
+interface DropdownProps {
+ name: string;
+ label: string;
+ values: readonly T[];
+ size?: 'md' | 'sm';
+ defaultValue?: T;
+ placeholder?: string;
+ className?: string;
+}
+
+// EX :
+const Dropdown = ({
+ name,
+ label,
+ values,
+ size = 'md',
+ defaultValue,
+ placeholder = '선택해주세요',
+ className,
+}: DropdownProps) => {
+ const { value: isOpen, toggle, setClose } = useToggle();
+ const [selected, setSelected] = useState(defaultValue);
+ const dropdownRef = useRef(null);
+
+ const handleSelect = (value: T) => {
+ setSelected(value);
+ setClose();
+ };
+
+ useClickOutside(dropdownRef, () => setClose());
+
+ return (
+
+ {/* form 제출 대응 */}
+
+
+ {/* 옵션 버튼 */}
+
+
+ {/* 옵션 리스트 */}
+ {isOpen && (
+
+ {values.map(value => (
+
+ ))}
+
+ )}
+
+ );
+};
+export default Dropdown;
diff --git a/src/components/ui/dropdown/index.ts b/src/components/ui/dropdown/index.ts
new file mode 100644
index 0000000..5a4b926
--- /dev/null
+++ b/src/components/ui/dropdown/index.ts
@@ -0,0 +1 @@
+export { default as Dropdown } from './dropdown';
diff --git a/src/stories/DesignTokens/Icon.stories.tsx b/src/components/ui/icon/Icon.stories.tsx
similarity index 100%
rename from src/stories/DesignTokens/Icon.stories.tsx
rename to src/components/ui/icon/Icon.stories.tsx
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 127303e..d263c1a 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1 +1,2 @@
+export { Dropdown } from './dropdown';
export { Icon } from './icon';
diff --git a/src/constants/dropdown.ts b/src/constants/dropdown.ts
new file mode 100644
index 0000000..6ce0755
--- /dev/null
+++ b/src/constants/dropdown.ts
@@ -0,0 +1,43 @@
+export const ADDRESS_CODE = [
+ '서울시 종로구',
+ '서울시 중구',
+ '서울시 용산구',
+ '서울시 성동구',
+ '서울시 광진구',
+ '서울시 동대문구',
+ '서울시 중랑구',
+ '서울시 성북구',
+ '서울시 강북구',
+ '서울시 도봉구',
+ '서울시 노원구',
+ '서울시 은평구',
+ '서울시 서대문구',
+ '서울시 마포구',
+ '서울시 양천구',
+ '서울시 강서구',
+ '서울시 구로구',
+ '서울시 금천구',
+ '서울시 영등포구',
+ '서울시 동작구',
+ '서울시 관악구',
+ '서울시 서초구',
+ '서울시 강남구',
+ '서울시 송파구',
+ '서울시 강동구',
+] as const;
+export type AddressCode = (typeof ADDRESS_CODE)[number];
+
+export const CATEGORY_CODE = [
+ '한식',
+ '중식',
+ '일식',
+ '양식',
+ '분식',
+ '카페',
+ '편의점',
+ '기타',
+] as const;
+export type CategoryCode = (typeof CATEGORY_CODE)[number];
+
+export const SORT_CODE = ['마감 임박 순', '시급 많은 순', '시간 적은 순', '가나다 순'] as const;
+export type SortCode = (typeof SORT_CODE)[number];
diff --git a/src/constants/icon.ts b/src/constants/icon.ts
index 6d09dd9..a2528d4 100644
--- a/src/constants/icon.ts
+++ b/src/constants/icon.ts
@@ -53,6 +53,7 @@ export const ICONS = {
export type IconName = keyof typeof ICONS;
export const ICON_SIZES = {
+ 'x-sm': 'w-2.5 h-2.5',
sm: 'w-4 h-4',
rg: 'w-5 h-5',
md: 'w-6 h-6',
diff --git a/src/hooks/useClickOutside.tsx b/src/hooks/useClickOutside.tsx
new file mode 100644
index 0000000..97b601e
--- /dev/null
+++ b/src/hooks/useClickOutside.tsx
@@ -0,0 +1,23 @@
+import { useEffect } from 'react';
+
+const useClickOutside = (
+ ref: React.RefObject,
+ handler: (event: MouseEvent | TouchEvent) => void
+): void => {
+ useEffect(() => {
+ const listener = (event: MouseEvent | TouchEvent) => {
+ if (!ref.current || ref.current.contains(event.target as Node)) return;
+ handler(event);
+ };
+
+ document.addEventListener('mousedown', listener);
+ document.addEventListener('touchstart', listener);
+
+ return () => {
+ document.removeEventListener('mousedown', listener);
+ document.removeEventListener('touchstart', listener);
+ };
+ }, [ref, handler]);
+};
+
+export default useClickOutside;
diff --git a/src/hooks/useToggle.tsx b/src/hooks/useToggle.tsx
new file mode 100644
index 0000000..2ed7dd3
--- /dev/null
+++ b/src/hooks/useToggle.tsx
@@ -0,0 +1,17 @@
+import { useCallback, useState } from 'react';
+
+interface UseToggle {
+ value: boolean;
+ toggle: () => void;
+ setOpen: () => void;
+ setClose: () => void;
+}
+
+const useToggle = (init = false): UseToggle => {
+ const [value, setValue] = useState(init);
+ const toggle = useCallback(() => setValue(prev => !prev), []);
+ const setOpen = useCallback(() => setValue(true), []);
+ const setClose = useCallback(() => setValue(false), []);
+ return { value, toggle, setOpen, setClose };
+};
+export default useToggle;
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index e17e9d1..841c1de 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -1,4 +1,4 @@
-import { Container, Header, Wrapper } from '@/components/layout';
+import { Header, Wrapper } from '@/components/layout';
import ToastProvider from '@/context/toastContext/toastContext';
import '@/styles/fonts.css';
import '@/styles/globals.css';
@@ -17,9 +17,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
(page => (
-
- {page}
-
+ {page}
));
diff --git a/src/styles/globals.css b/src/styles/globals.css
index c00bc00..68ef159 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -139,3 +139,30 @@ body {
-webkit-mask-position: center;
-webkit-mask-size: contain;
}
+
+.base-input {
+ @apply rounded-md border border-gray-300 bg-white px-5 py-4 text-body-l placeholder:text-gray-400;
+}
+
+.scroll-bar {
+ overflow: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--gray-400) transparent;
+ scroll-behavior: smooth;
+ &::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ background: transparent;
+ }
+ &::-webkit-scrollbar-track {
+ margin: 4px 0;
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ border-radius: 40px;
+ background-color: var(--gray-400);
+ }
+ &::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index e97325b..5f435fe 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -77,6 +77,9 @@ const config: Config = {
tablet: '744px',
desktop: '1028px',
},
+ boxShadow: {
+ 'inset-top': '0 -4px 25px 0 rgba(0,0,0,0.1)',
+ },
},
},
plugins: [],