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: [],