diff --git a/src/components/ui/dropdown/dropdown.stories.tsx b/src/components/ui/dropdown/dropdown.stories.tsx index baefeb1..47b3e9c 100644 --- a/src/components/ui/dropdown/dropdown.stories.tsx +++ b/src/components/ui/dropdown/dropdown.stories.tsx @@ -12,7 +12,7 @@ const meta: Meta = { tags: ['autodocs'], args: { name: 'status', - label: '카테고리', + areaLabel: '카테고리', placeholder: '카테고리를 선택하세요', values: CATEGORY_CODE, // 기본값 }, diff --git a/src/components/ui/dropdown/dropdown.styles.ts b/src/components/ui/dropdown/dropdown.styles.ts new file mode 100644 index 0000000..b0fe9be --- /dev/null +++ b/src/components/ui/dropdown/dropdown.styles.ts @@ -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; diff --git a/src/components/ui/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx index fe27c6f..f9345fb 100644 --- a/src/components/ui/dropdown/dropdown.tsx +++ b/src/components/ui/dropdown/dropdown.tsx @@ -1,28 +1,16 @@ 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 { name: string; - label: string; + areaLabel: string; values: readonly T[]; size?: 'md' | 'sm'; defaultValue?: T; @@ -30,10 +18,10 @@ interface DropdownProps { className?: string; } -// EX : +// EX : const Dropdown = ({ name, - label, + areaLabel: label, values, size = 'md', defaultValue, @@ -42,24 +30,32 @@ const Dropdown = ({ }: DropdownProps) => { const { value: isOpen, toggle, setClose } = useToggle(); const [selected, setSelected] = useState(defaultValue); - const dropdownRef = useRef(null); - + const [attachDropdownRef, dropdownRef] = useSafeRef(); + const [attachTriggerRef, triggerRef] = useSafeRef(); + const [attachListRef, listRef] = useSafeRef(); 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 ( -
+
{/* form 제출 대응 */} {/* 옵션 버튼 */} diff --git a/src/components/ui/dropdown/hooks/useDropdown.ts b/src/components/ui/dropdown/hooks/useDropdown.ts new file mode 100644 index 0000000..76e161c --- /dev/null +++ b/src/components/ui/dropdown/hooks/useDropdown.ts @@ -0,0 +1,33 @@ +import { RefObject } from 'react'; +import useDropdownPosition from './useDropdownPosition'; +import useDropdownScroll from './useDropdownScroll'; +import useKeyboardNavigation from './useKeyboardNavigation'; + +interface UseDropdownProps { + values: readonly T[]; + isOpen: boolean; + triggerRef: RefObject; + listRef: RefObject; + onSelect: (value: T) => void; +} + +const useDropdown = ({ + values, + isOpen, + triggerRef, + listRef, + onSelect, +}: UseDropdownProps) => { + const position = useDropdownPosition(triggerRef); + const { cursorIndex, setCursorIndex } = useKeyboardNavigation({ isOpen, values, onSelect }); + + useDropdownScroll(listRef, cursorIndex); + + return { + cursorIndex, + position, + setCursorIndex, + }; +}; + +export default useDropdown; diff --git a/src/components/ui/dropdown/hooks/useDropdownPosition.ts b/src/components/ui/dropdown/hooks/useDropdownPosition.ts new file mode 100644 index 0000000..4ce818b --- /dev/null +++ b/src/components/ui/dropdown/hooks/useDropdownPosition.ts @@ -0,0 +1,28 @@ +import { RefObject, useEffect, useState } from 'react'; + +const useDropdownPosition = (triggerRef: RefObject) => { + 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; diff --git a/src/components/ui/dropdown/hooks/useDropdownScroll.ts b/src/components/ui/dropdown/hooks/useDropdownScroll.ts new file mode 100644 index 0000000..cf00cbe --- /dev/null +++ b/src/components/ui/dropdown/hooks/useDropdownScroll.ts @@ -0,0 +1,20 @@ +import { RefObject, useEffect } from 'react'; + +const useDropdownScroll = (listRef: RefObject, 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; diff --git a/src/components/ui/dropdown/hooks/useKeyboardNavigation.ts b/src/components/ui/dropdown/hooks/useKeyboardNavigation.ts new file mode 100644 index 0000000..66d52f6 --- /dev/null +++ b/src/components/ui/dropdown/hooks/useKeyboardNavigation.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; + +interface UseKeyboardNavigationProps { + isOpen: boolean; + values: readonly T[]; + onSelect: (value: T) => void; +} + +const useKeyboardNavigation = ({ + isOpen, + values, + onSelect, +}: UseKeyboardNavigationProps) => { + const [cursorIndex, setCursorIndex] = useState(-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; diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.ts similarity index 100% rename from src/hooks/useAuth.tsx rename to src/hooks/useAuth.ts diff --git a/src/hooks/useClickOutside.tsx b/src/hooks/useClickOutside.ts similarity index 61% rename from src/hooks/useClickOutside.tsx rename to src/hooks/useClickOutside.ts index 97b601e..fbeccf6 100644 --- a/src/hooks/useClickOutside.tsx +++ b/src/hooks/useClickOutside.ts @@ -1,13 +1,16 @@ import { useEffect } from 'react'; +// @example : useClickOutside(dropdownRef, setClose); +type ClickOutsideHandler = (e: MouseEvent | TouchEvent) => void; + const useClickOutside = ( ref: React.RefObject, - 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); diff --git a/src/hooks/useEscapeKey.ts b/src/hooks/useEscapeKey.ts new file mode 100644 index 0000000..ff8fc9c --- /dev/null +++ b/src/hooks/useEscapeKey.ts @@ -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; diff --git a/src/hooks/useSafeRef.ts b/src/hooks/useSafeRef.ts new file mode 100644 index 0000000..7d34e64 --- /dev/null +++ b/src/hooks/useSafeRef.ts @@ -0,0 +1,14 @@ +import { useCallback, useRef } from 'react'; + +// 안전하게 DOM 접근을 위한 hook +// DOM이 없어질 수 있는 타이밍(언마운트, 조건부 렌더링 등)을 대비해서 존재 여부 확인 +const useSafeRef = () => { + const ref = useRef(null); + + const setRef = useCallback((node: T | null) => { + ref.current = node; + }, []); + + return [setRef, ref] as const; +}; +export default useSafeRef; diff --git a/src/hooks/useToggle.tsx b/src/hooks/useToggle.ts similarity index 100% rename from src/hooks/useToggle.tsx rename to src/hooks/useToggle.ts diff --git a/src/lib/utils/cn.ts b/src/lib/utils/cn.ts index 58c525f..693481d 100644 --- a/src/lib/utils/cn.ts +++ b/src/lib/utils/cn.ts @@ -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', + ], + }, + ], }, }, });