From d6393157792d5593d0949b179ee3bf9c98b6e9f3 Mon Sep 17 00:00:00 2001 From: SeongHwan Date: Sun, 26 Jul 2026 20:30:32 +0900 Subject: [PATCH 01/33] =?UTF-8?q?feat:=20Toast=C2=B7SelectDropdown=C2=B7Sk?= =?UTF-8?q?eleton=20=EA=B3=B5=EC=9A=A9=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/stores/useToastStore.ts | 44 ++++++++ src/shared/ui/common/SelectDropdown.tsx | 131 ++++++++++++++++++++++++ src/shared/ui/common/Skeleton.tsx | 17 +++ src/shared/ui/common/Toast.tsx | 71 +++++++++++++ src/shared/ui/common/ToastViewport.tsx | 25 +++++ tailwind.config.js | 10 ++ 6 files changed, 298 insertions(+) create mode 100644 src/shared/stores/useToastStore.ts create mode 100644 src/shared/ui/common/SelectDropdown.tsx create mode 100644 src/shared/ui/common/Skeleton.tsx create mode 100644 src/shared/ui/common/Toast.tsx create mode 100644 src/shared/ui/common/ToastViewport.tsx diff --git a/src/shared/stores/useToastStore.ts b/src/shared/stores/useToastStore.ts new file mode 100644 index 00000000..1d5460c8 --- /dev/null +++ b/src/shared/stores/useToastStore.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand' + +export type ToastVariant = 'success' | 'error' + +export interface ToastItem { + id: number + message: string + variant: ToastVariant +} + +/** 자동 dismiss 지연(ms) */ +export const TOAST_DURATION = 2000 + +interface ToastState { + toasts: ToastItem[] + showToast: (message: string, variant?: ToastVariant) => void + dismissToast: (id: number) => void +} + +let toastSeq = 0 + +export const useToastStore = create(set => ({ + toasts: [], + showToast: (message, variant = 'success') => { + toastSeq += 1 + const id = toastSeq + set(state => ({ toasts: [...state.toasts, { id, message, variant }] })) + setTimeout(() => { + set(state => ({ toasts: state.toasts.filter(t => t.id !== id) })) + }, TOAST_DURATION) + }, + dismissToast: id => + set(state => ({ toasts: state.toasts.filter(t => t.id !== id) })), +})) + +/** + * 컴포넌트 외부(핸들러·훅)에서도 호출 가능한 단축 함수 + * 예) showToast('최종합격 처리됐어요') + */ +export function showToast(message: string, variant: ToastVariant = 'success') { + useToastStore.getState().showToast(message, variant) +} + +export default useToastStore diff --git a/src/shared/ui/common/SelectDropdown.tsx b/src/shared/ui/common/SelectDropdown.tsx new file mode 100644 index 00000000..89832234 --- /dev/null +++ b/src/shared/ui/common/SelectDropdown.tsx @@ -0,0 +1,131 @@ +import { useEffect, useId, useRef, useState } from 'react' + +import DownIcon from '@/assets/icons/home/chevron-down.svg?react' +import { cn } from '@/shared/lib/utils' + +export interface SelectOption { + value: T + label: string +} + +interface SelectDropdownProps { + options: SelectOption[] + value: T + onChange: (value: T) => void + /** 접근성 라벨 (예: '업장 필터') */ + ariaLabel: string + placeholder?: string + disabled?: boolean + className?: string +} + +/** + * 박스형 Select — FilterBar(업장·상태), 공고 등록 폼(업장 선택) 등에 사용. + * 외부 클릭·Escape로 닫히며, 옵션은 문자열/숫자 값을 지원합니다. + */ +export function SelectDropdown({ + options, + value, + onChange, + ariaLabel, + placeholder = '선택', + disabled = false, + className, +}: SelectDropdownProps) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + const listId = useId() + + useEffect(() => { + if (!isOpen) return + + function handleOutsideClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false) + } + } + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setIsOpen(false) + } + + document.addEventListener('mousedown', handleOutsideClick) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleOutsideClick) + document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen]) + + const selected = options.find(option => option.value === value) + + return ( +
+ + + {isOpen ? ( +
    + {options.map(option => { + const isSelected = option.value === value + return ( +
  • + +
  • + ) + })} +
+ ) : null} +
+ ) +} diff --git a/src/shared/ui/common/Skeleton.tsx b/src/shared/ui/common/Skeleton.tsx new file mode 100644 index 00000000..a5f5b73f --- /dev/null +++ b/src/shared/ui/common/Skeleton.tsx @@ -0,0 +1,17 @@ +import { cn } from '@/shared/lib/utils' + +interface SkeletonProps { + className?: string +} + +/** 로딩 플레이스홀더 블록 */ +export function Skeleton({ className }: SkeletonProps) { + return ( +