Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', // ?? 연산자 사용 권장
Expand Down
4 changes: 1 addition & 3 deletions src/components/layout/container/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,19 @@ interface Props {
as?: ElementType;
className?: string;
children: ReactNode;
grow?: boolean;
}

export const Wrapper = ({ children }: { children: ReactNode }) => {
return <div className='flex min-h-screen flex-col'>{children}</div>;
};

const Container = ({ as: Component = 'main', grow = false, className, children }: Props) => {
const Container = ({ as: Component = 'div', className, children }: Props) => {
return (
<Component
className={cn(
'relative z-[1]',
'mx-auto w-full max-w-[1028px] px-3',
'tablet:px-8',
grow && 'grow',
className
)}
>
Expand Down
49 changes: 49 additions & 0 deletions src/components/ui/dropdown/dropdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Dropdown> = {
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<typeof Dropdown>;

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],
},
};
110 changes: 110 additions & 0 deletions src/components/ui/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends string> {
name: string;
label: string;
values: readonly T[];
size?: 'md' | 'sm';
defaultValue?: T;
placeholder?: string;
className?: string;
}

// EX : <Dropdown name="formName" label="접근성라벨" values={ADDRESS_CODE} />
const Dropdown = <T extends string>({
name,
label,
values,
size = 'md',
defaultValue,
placeholder = '선택해주세요',
className,
}: DropdownProps<T>) => {
const { value: isOpen, toggle, setClose } = useToggle();
const [selected, setSelected] = useState<T | undefined>(defaultValue);
const dropdownRef = useRef<HTMLDivElement>(null);

const handleSelect = (value: T) => {
setSelected(value);
setClose();
};

useClickOutside(dropdownRef, () => setClose());

return (
<div className={cn('relative inline-flex', className)} ref={dropdownRef}>
{/* form 제출 대응 */}
<input type='hidden' name={name} value={selected ?? ''} />

{/* 옵션 버튼 */}
<button
type='button'
aria-haspopup='listbox'
aria-expanded={isOpen}
aria-label={label}
className={cn(
DROPDOWN_STYLE['base'],
size === 'md' ? DROPDOWN_STYLE['md'] : DROPDOWN_STYLE['sm']
)}
onClick={toggle}
>
{selected ?? <span className='text-gray-400'>{placeholder}</span>}
<Icon
iconName={isOpen ? 'dropdownUp' : 'dropdownDown'}
iconSize={size === 'md' ? 'sm' : 'x-sm'}
ariaLabel='옵션선택'
className={cn(
DROPDOWN_ICON_STYLE['base'],
size === 'md' ? DROPDOWN_ICON_STYLE['md'] : DROPDOWN_ICON_STYLE['sm']
)}
/>
</button>

{/* 옵션 리스트 */}
{isOpen && (
<div
role='listbox'
aria-label={label}
className='scroll-bar shadow-inset-top absolute top-[calc(100%+8px)] z-[1] max-h-56 w-full rounded-md border border-gray-300 bg-white'
>
{values.map(value => (
<button
key={value}
role='option'
aria-selected={selected === value}
className={cn(
DROPDOWN_ITEM_STYLE['base'],
size === 'md' ? DROPDOWN_ITEM_STYLE['md'] : DROPDOWN_ITEM_STYLE['sm'],
selected === value && 'bg-red-100 font-bold'
)}
onClick={() => handleSelect(value)}
>
{value}
</button>
))}
</div>
)}
</div>
);
};
export default Dropdown;
1 change: 1 addition & 0 deletions src/components/ui/dropdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Dropdown } from './dropdown';
1 change: 1 addition & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Dropdown } from './dropdown';
export { Icon } from './icon';
43 changes: 43 additions & 0 deletions src/constants/dropdown.ts
Original file line number Diff line number Diff line change
@@ -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];
1 change: 1 addition & 0 deletions src/constants/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
23 changes: 23 additions & 0 deletions src/hooks/useClickOutside.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect } from 'react';

const useClickOutside = <T extends HTMLElement>(
ref: React.RefObject<T>,
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;
17 changes: 17 additions & 0 deletions src/hooks/useToggle.tsx
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 2 additions & 4 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,9 +17,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
(page => (
<Wrapper>
<Header />
<Container as='main' grow>
{page}
</Container>
<main className='grow'>{page}</main>
</Wrapper>
));

Expand Down
27 changes: 27 additions & 0 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
3 changes: 3 additions & 0 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down