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 src/components/ui/dropdown/dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const meta: Meta<typeof Dropdown> = {
tags: ['autodocs'],
args: {
name: 'status',
label: '카테고리',
areaLabel: '카테고리',
placeholder: '카테고리를 선택하세요',
values: CATEGORY_CODE, // 기본값
},
Expand Down
16 changes: 16 additions & 0 deletions src/components/ui/dropdown/dropdown.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const DROPDOWN_STYLE = {
base: 'flex-1 text-left min-w-[110px] focus-visible:outline-red-300',
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;
export const DROPDOWN_ICON_STYLE = {
base: 'absolute top-1/2 -translate-y-1/2',
md: 'right-5',
sm: 'right-3',
} as const;

export 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-100',
md: 'py-3',
sm: 'py-2',
} as const;
61 changes: 31 additions & 30 deletions src/components/ui/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,27 @@
import { Icon } from '@/components/ui/icon';
import useClickOutside from '@/hooks/useClickOutside';
import useEscapeKey from '@/hooks/useEscapeKey';
import useSafeRef from '@/hooks/useSafeRef';
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;
import { useState } from 'react';
import { DROPDOWN_ICON_STYLE, DROPDOWN_ITEM_STYLE, DROPDOWN_STYLE } from './dropdown.styles';
import useDropdown from './hooks/useDropdown';

interface DropdownProps<T extends string> {
name: string;
label: string;
areaLabel: string;
values: readonly T[];
size?: 'md' | 'sm';
defaultValue?: T;
placeholder?: string;
className?: string;
}

// EX : <Dropdown name="formName" label="접근성라벨" values={ADDRESS_CODE} />
// EX : <Dropdown name="formName" areaLabel="접근성라벨" values={ADDRESS_CODE} />
const Dropdown = <T extends string>({
name,
label,
areaLabel: label,
values,
size = 'md',
defaultValue,
Expand All @@ -42,24 +30,32 @@ const Dropdown = <T extends string>({
}: DropdownProps<T>) => {
const { value: isOpen, toggle, setClose } = useToggle();
const [selected, setSelected] = useState<T | undefined>(defaultValue);
const dropdownRef = useRef<HTMLDivElement>(null);

const [attachDropdownRef, dropdownRef] = useSafeRef<HTMLDivElement>();
const [attachTriggerRef, triggerRef] = useSafeRef<HTMLButtonElement>();
const [attachListRef, listRef] = useSafeRef<HTMLDivElement>();
const handleSelect = (value: T) => {
setSelected(value);
setClose();
};

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

const { cursorIndex, position } = useDropdown({
values,
isOpen,
listRef,
triggerRef,
onSelect: handleSelect,
});
useClickOutside(dropdownRef, setClose);
useEscapeKey(setClose);
return (
<div className={cn('relative inline-flex', className)} ref={dropdownRef}>
<div className={cn('relative inline-flex', className)} ref={attachDropdownRef}>
{/* form 제출 대응 */}
<input type='hidden' name={name} value={selected ?? ''} />

{/* 옵션 버튼 */}
<button
ref={attachTriggerRef}
type='button'
aria-haspopup='listbox'
aria-expanded={isOpen}
aria-label={label}
className={cn(
Expand All @@ -83,21 +79,26 @@ const Dropdown = <T extends string>({
{/* 옵션 리스트 */}
{isOpen && (
<div
ref={attachListRef}
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'
className={cn(
'scroll-bar absolute z-[1] max-h-56 w-full rounded-md border border-gray-300 bg-white shadow-inset-top',
position === 'top' ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+8px)]'
)}
>
{values.map(value => (
{values.map((value, index) => (
<button
key={value}
role='option'
aria-selected={selected === value}
onClick={() => handleSelect(value)}
className={cn(
DROPDOWN_ITEM_STYLE['base'],
size === 'md' ? DROPDOWN_ITEM_STYLE['md'] : DROPDOWN_ITEM_STYLE['sm'],
selected === value && 'bg-red-100 font-bold'
selected === value && 'bg-red-200 font-bold',
cursorIndex === index && 'bg-gray-100'
)}
onClick={() => handleSelect(value)}
>
{value}
</button>
Expand Down
33 changes: 33 additions & 0 deletions src/components/ui/dropdown/hooks/useDropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { RefObject } from 'react';
import useDropdownPosition from './useDropdownPosition';
import useDropdownScroll from './useDropdownScroll';
import useKeyboardNavigation from './useKeyboardNavigation';

interface UseDropdownProps<T extends string> {
values: readonly T[];
isOpen: boolean;
triggerRef: RefObject<HTMLButtonElement>;
listRef: RefObject<HTMLDivElement>;
onSelect: (value: T) => void;
}

const useDropdown = <T extends string>({
values,
isOpen,
triggerRef,
listRef,
onSelect,
}: UseDropdownProps<T>) => {
const position = useDropdownPosition(triggerRef);
const { cursorIndex, setCursorIndex } = useKeyboardNavigation({ isOpen, values, onSelect });

useDropdownScroll(listRef, cursorIndex);

return {
cursorIndex,
position,
setCursorIndex,
};
};

export default useDropdown;
28 changes: 28 additions & 0 deletions src/components/ui/dropdown/hooks/useDropdownPosition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { RefObject, useEffect, useState } from 'react';

const useDropdownPosition = (triggerRef: RefObject<HTMLButtonElement>) => {
const [position, setPosition] = useState<'top' | 'bottom'>('bottom');

useEffect(() => {
const trigger = triggerRef.current;
if (!trigger) return;

const updatePosition = () => {
const rect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
setPosition(viewportHeight - rect.bottom < 300 ? 'top' : 'bottom');
};

updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [triggerRef]);

return position;
};

export default useDropdownPosition;
20 changes: 20 additions & 0 deletions src/components/ui/dropdown/hooks/useDropdownScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { RefObject, useEffect } from 'react';

const useDropdownScroll = (listRef: RefObject<HTMLDivElement>, cursorIndex: number) => {
useEffect(() => {
const list = listRef.current;
if (!list || cursorIndex < 0) return;

const item = list.children[cursorIndex] as HTMLElement | undefined;
if (!item) return;
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const viewTop = list.scrollTop;
const viewBottom = viewTop + list.clientHeight;

if (itemTop < viewTop) list.scrollTop = itemTop;
else if (itemBottom > viewBottom) list.scrollTop = itemBottom - list.clientHeight;
}, [cursorIndex, listRef]);
};

export default useDropdownScroll;
53 changes: 53 additions & 0 deletions src/components/ui/dropdown/hooks/useKeyboardNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';

interface UseKeyboardNavigationProps<T extends string> {
isOpen: boolean;
values: readonly T[];
onSelect: (value: T) => void;
}

const useKeyboardNavigation = <T extends string>({
isOpen,
values,
onSelect,
}: UseKeyboardNavigationProps<T>) => {
const [cursorIndex, setCursorIndex] = useState<number>(-1);

useEffect(() => {
if (!isOpen) {
setCursorIndex(-1);
return;
}

const total = values.length;
if (!total) return;

// 방향키를 한 방향으로 누를때 순환 구조 로직
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setCursorIndex(prev => (prev + 1) % total);
break;
case 'ArrowUp':
e.preventDefault();
setCursorIndex(prev => (prev - 1 + total) % total);
break;
case 'Enter':
e.preventDefault();
if (cursorIndex >= 0) onSelect(values[cursorIndex]);
break;
case 'Escape':
e.preventDefault();
setCursorIndex(-1);
break;
}
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, values, cursorIndex, onSelect]);

return { cursorIndex, setCursorIndex };
};
export default useKeyboardNavigation;
File renamed without changes.
11 changes: 7 additions & 4 deletions src/hooks/useClickOutside.tsx → src/hooks/useClickOutside.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { useEffect } from 'react';

// @example : useClickOutside(dropdownRef, setClose);
type ClickOutsideHandler = (e: MouseEvent | TouchEvent) => void;

const useClickOutside = <T extends HTMLElement>(
ref: React.RefObject<T>,
handler: (event: MouseEvent | TouchEvent) => void
handler: ClickOutsideHandler
): void => {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler(event);
const listener: ClickOutsideHandler = e => {
if (!ref.current || ref.current.contains(e.target as Node)) return;
handler(e);
};

document.addEventListener('mousedown', listener);
Expand Down
18 changes: 18 additions & 0 deletions src/hooks/useEscapeKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useEffect } from 'react';

// @example : useEscapeKey(setClose);
type EscapeHandler = (e: KeyboardEvent) => void;

const useEscapeKey = (handler: EscapeHandler) => {
useEffect(() => {
const listener: EscapeHandler = e => {
if (e.key !== 'Escape') return;
handler(e);
};

document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, [handler]);
};

export default useEscapeKey;
14 changes: 14 additions & 0 deletions src/hooks/useSafeRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useCallback, useRef } from 'react';

// 안전하게 DOM 접근을 위한 hook
// DOM이 없어질 수 있는 타이밍(언마운트, 조건부 렌더링 등)을 대비해서 존재 여부 확인
const useSafeRef = <T extends HTMLElement>() => {
const ref = useRef<T | null>(null);

const setRef = useCallback((node: T | null) => {
ref.current = node;
}, []);

return [setRef, ref] as const;
};
export default useSafeRef;
File renamed without changes.
15 changes: 14 additions & 1 deletion src/lib/utils/cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,20 @@ import { extendTailwindMerge } from 'tailwind-merge';
const twMergeCustom = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ text: ['caption', 'modal'] }],
'font-size': [
{
text: [
'caption',
'modal',
'body-s',
'body-m',
'body-l',
'heading-s',
'heading-m',
'heading-l',
],
},
],
},
},
});
Expand Down