From 6708a8b0b59f3372b189256024243bb683470b16 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 01:25:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/checkbox/CheckBox.tsx | 57 ++++++++++++++++ .../checkbox/constants/constants.ts | 20 ++++++ .../checkbox/styles/CheckBox.module.css | 65 +++++++++++++++++++ src/components/checkbox/types/types.ts | 24 +++++++ 4 files changed, 166 insertions(+) create mode 100644 src/components/checkbox/CheckBox.tsx create mode 100644 src/components/checkbox/constants/constants.ts create mode 100644 src/components/checkbox/styles/CheckBox.module.css create mode 100644 src/components/checkbox/types/types.ts diff --git a/src/components/checkbox/CheckBox.tsx b/src/components/checkbox/CheckBox.tsx new file mode 100644 index 0000000..f3630de --- /dev/null +++ b/src/components/checkbox/CheckBox.tsx @@ -0,0 +1,57 @@ +import clsx from 'clsx'; +import Image from 'next/image'; +import type { ChangeEvent } from 'react'; + +import styles from './styles/CheckBox.module.css'; +import { CHECKBOX_ICON, ICON_SIZE } from './constants/constants'; +import type { CheckBoxProps } from './types/types'; + +/** + * 체크박스 컴포넌트. + * @param isChecked 체크 여부(필수) + * @param onChange 체크 상태 변경 콜백 (필요 시) + * @param size 체크박스 크기('large' | 'small') + * @param label 표시 라벨(없으면 ariaLabel 필수) + * @param ariaLabel 라벨이 없을 때 사용하는 접근성 라벨(필수) + */ +export default function CheckBox({ + isChecked, + size = 'large', + label, + ariaLabel, + id, + name, + value, + disabled = false, + className, + onChange, +}: CheckBoxProps) { + const iconSrc = isChecked ? CHECKBOX_ICON.checked[size] : CHECKBOX_ICON.unchecked[size]; + const iconSize = ICON_SIZE[size]; + const inputAriaLabel = label ? undefined : ariaLabel; + + const handleChange = (event: ChangeEvent) => { + onChange?.(event.target.checked); + }; + + return ( + + ); +} diff --git a/src/components/checkbox/constants/constants.ts b/src/components/checkbox/constants/constants.ts new file mode 100644 index 0000000..3085b5c --- /dev/null +++ b/src/components/checkbox/constants/constants.ts @@ -0,0 +1,20 @@ +import checkedLarge from '@/assets/icons/check/checkedLarge.svg'; +import checkedSmall from '@/assets/icons/check/checkedSmall.svg'; +import nonCheckedLarge from '@/assets/icons/check/nonChekcedLarge.svg'; +import nonCheckedSmall from '@/assets/icons/check/nonChekcedSmall.svg'; + +export const CHECKBOX_ICON = { + checked: { + large: checkedLarge, + small: checkedSmall, + }, + unchecked: { + large: nonCheckedLarge, + small: nonCheckedSmall, + }, +} as const; + +export const ICON_SIZE = { + large: 18, + small: 16, +} as const; diff --git a/src/components/checkbox/styles/CheckBox.module.css b/src/components/checkbox/styles/CheckBox.module.css new file mode 100644 index 0000000..af6a392 --- /dev/null +++ b/src/components/checkbox/styles/CheckBox.module.css @@ -0,0 +1,65 @@ +.checkbox { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.large { + font-size: 16px; +} + +.small { + font-size: 14px; +} + +.input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + pointer-events: none; +} + +.box { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.small .box { + width: 16px; + height: 16px; +} + +.icon { + width: 100%; + height: 100%; + display: block; +} + +.label { + line-height: 1.4; +} + +.disabled { + cursor: not-allowed; + opacity: 0.5; +} + +@media (max-width: 480px) { + .checkbox { + gap: 6px; + } + + .large { + font-size: 15px; + } + + .small { + font-size: 13px; + } +} diff --git a/src/components/checkbox/types/types.ts b/src/components/checkbox/types/types.ts new file mode 100644 index 0000000..18a1ccc --- /dev/null +++ b/src/components/checkbox/types/types.ts @@ -0,0 +1,24 @@ +export type CheckBoxSize = 'large' | 'small'; + +interface CheckBoxBaseProps { + isChecked: boolean; + size?: CheckBoxSize; + id?: string; + name?: string; + value?: string; + disabled?: boolean; + className?: string; + onChange?: (isChecked: boolean) => void; +} + +export interface CheckBoxPropsWithLabel extends CheckBoxBaseProps { + label: string; + ariaLabel?: string; +} + +export interface CheckBoxPropsWithAriaLabel extends CheckBoxBaseProps { + label?: undefined; + ariaLabel: string; +} + +export type CheckBoxProps = CheckBoxPropsWithLabel | CheckBoxPropsWithAriaLabel; From 463add57ee4983ee87d704a85f7d9196346c933f Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 01:26:40 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=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/components/toast/Toast.tsx | 56 +++++++++ .../toast/hooks/useToastLifecycle.ts | 83 +++++++++++++ src/components/toast/styles/Toast.module.css | 116 ++++++++++++++++++ src/components/toast/types/types.ts | 14 +++ 4 files changed, 269 insertions(+) create mode 100644 src/components/toast/Toast.tsx create mode 100644 src/components/toast/hooks/useToastLifecycle.ts create mode 100644 src/components/toast/styles/Toast.module.css create mode 100644 src/components/toast/types/types.ts diff --git a/src/components/toast/Toast.tsx b/src/components/toast/Toast.tsx new file mode 100644 index 0000000..c0c67a6 --- /dev/null +++ b/src/components/toast/Toast.tsx @@ -0,0 +1,56 @@ +'use client'; + +import clsx from 'clsx'; + +import useToastLifecycle from './hooks/useToastLifecycle'; +import styles from './styles/Toast.module.css'; +import type { ToastProps } from './types/types'; + +const DEFAULT_AUTO_DISMISS_MS = 3000; +const DEFAULT_ANIMATION_MS = 600; + +export default function Toast({ + message = '저장하지 않은 변경사항이 있어요!', + actionLabel = '변경사항 저장하기', + isOpen = true, + autoDismissMs = DEFAULT_AUTO_DISMISS_MS, + enterDurationMs = DEFAULT_ANIMATION_MS, + exitDurationMs = DEFAULT_ANIMATION_MS, + className, + actionClassName, + onAction, + onDismiss, +}: ToastProps) { + const { isRendered, isClosing } = useToastLifecycle({ + isOpen, + autoDismissMs, + exitDurationMs, + onDismiss, + }); + + return isRendered ? ( +
+
+ + {message} +
+ {actionLabel ? ( + + ) : null} +
+ ) : null; +} diff --git a/src/components/toast/hooks/useToastLifecycle.ts b/src/components/toast/hooks/useToastLifecycle.ts new file mode 100644 index 0000000..7b005f8 --- /dev/null +++ b/src/components/toast/hooks/useToastLifecycle.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface UseToastLifecycleOptions { + isOpen: boolean; + autoDismissMs: number; + exitDurationMs: number; + onDismiss?: () => void; +} + +export default function useToastLifecycle({ + isOpen, + autoDismissMs, + exitDurationMs, + onDismiss, +}: UseToastLifecycleOptions) { + const [isRendered, setIsRendered] = useState(isOpen); + const [isClosing, setIsClosing] = useState(false); + const autoTimerRef = useRef(null); + const exitTimerRef = useRef(null); + const prevIsOpenRef = useRef(isOpen); + + const clearTimers = useCallback(() => { + if (autoTimerRef.current) { + window.clearTimeout(autoTimerRef.current); + autoTimerRef.current = null; + } + if (exitTimerRef.current) { + window.clearTimeout(exitTimerRef.current); + exitTimerRef.current = null; + } + }, []); + + const startDismiss = useCallback(() => { + if (exitTimerRef.current) return; + setIsClosing(true); + exitTimerRef.current = window.setTimeout(() => { + setIsRendered(false); + setIsClosing(false); + exitTimerRef.current = null; + onDismiss?.(); + }, exitDurationMs); + }, [exitDurationMs, onDismiss]); + + useEffect(() => { + let rafId: number | null = null; + const wasOpen = prevIsOpenRef.current; + + if (isOpen) { + prevIsOpenRef.current = true; + const isOpening = !wasOpen; + if (isOpening) { + clearTimers(); + rafId = window.requestAnimationFrame(() => { + setIsRendered(true); + setIsClosing(false); + }); + } + + if ((isOpening || isRendered) && !isClosing && autoDismissMs > 0) { + clearTimers(); + autoTimerRef.current = window.setTimeout(startDismiss, autoDismissMs); + } + + return () => { + if (rafId !== null) window.cancelAnimationFrame(rafId); + }; + } + + if (isRendered && !isClosing) { + clearTimers(); + rafId = window.requestAnimationFrame(() => startDismiss()); + } + prevIsOpenRef.current = false; + + return () => { + if (rafId !== null) window.cancelAnimationFrame(rafId); + }; + }, [autoDismissMs, clearTimers, isClosing, isOpen, isRendered, startDismiss]); + + useEffect(() => () => clearTimers(), [clearTimers]); + + return { isRendered, isClosing }; +} diff --git a/src/components/toast/styles/Toast.module.css b/src/components/toast/styles/Toast.module.css new file mode 100644 index 0000000..fdcf741 --- /dev/null +++ b/src/components/toast/styles/Toast.module.css @@ -0,0 +1,116 @@ +.toast { + position: fixed; + top: 20px; + left: 20px; + z-index: 1000; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 868px; + height: 57px; + padding: 12px 12px 12px 24px; + border-radius: 16px; + box-sizing: border-box; + background: var(--Color-Brand-Primary, #5189fa); + color: #ffffff; +} + +.content { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex: 0 0 auto; +} + +.message { + font-size: 14px; + line-height: 1.4; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.action { + flex: 0 0 auto; + border: none; + border-radius: 12px; + padding: 8px 12px; + background: #ffffff; + color: #3f6fe5; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.action:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 2px; +} + +.enter { + animation: toast-pop-in 600ms ease-out; +} + +.exit { + animation: toast-fade-out 600ms ease-in forwards; +} + +@keyframes toast-pop-in { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.96); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes toast-fade-out { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + } + 100% { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } +} + +@media (max-width: 768px) { + .toast { + top: 97px; + left: 20px; + width: 343px; + height: 49px; + padding: 8px 8px 8px 12px; + gap: 8px; + } + + .message { + font-size: 13px; + } + + .action { + padding: 6px 10px; + font-size: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + .enter, + .exit { + animation: none; + } +} diff --git a/src/components/toast/types/types.ts b/src/components/toast/types/types.ts new file mode 100644 index 0000000..bfe1bad --- /dev/null +++ b/src/components/toast/types/types.ts @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +export interface ToastProps { + message?: ReactNode; + actionLabel?: ReactNode; + isOpen?: boolean; + autoDismissMs?: number; + enterDurationMs?: number; + exitDurationMs?: number; + className?: string; + actionClassName?: string; + onAction?: () => void; + onDismiss?: () => void; +} From 6981cbb45fbd9c9c96decab0cb074b9ea8bbf4ee Mon Sep 17 00:00:00 2001 From: Jieunsse Date: Tue, 3 Feb 2026 23:00:32 +0900 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/components/checkbox/constants/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/checkbox/constants/constants.ts b/src/components/checkbox/constants/constants.ts index 3085b5c..a6d999c 100644 --- a/src/components/checkbox/constants/constants.ts +++ b/src/components/checkbox/constants/constants.ts @@ -1,7 +1,7 @@ import checkedLarge from '@/assets/icons/check/checkedLarge.svg'; import checkedSmall from '@/assets/icons/check/checkedSmall.svg'; -import nonCheckedLarge from '@/assets/icons/check/nonChekcedLarge.svg'; -import nonCheckedSmall from '@/assets/icons/check/nonChekcedSmall.svg'; +import nonCheckedLarge from '@/assets/icons/check/nonCheckedLarge.svg'; +import nonCheckedSmall from '@/assets/icons/check/nonCheckedSmall.svg'; export const CHECKBOX_ICON = { checked: { From 668875f30ba9ac8277a7d1674bd6c01fa4e7fc4c Mon Sep 17 00:00:00 2001 From: Jieunsse Date: Tue, 3 Feb 2026 23:00:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20useEffect=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/components/toast/hooks/useToastLifecycle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/toast/hooks/useToastLifecycle.ts b/src/components/toast/hooks/useToastLifecycle.ts index 7b005f8..a479fc2 100644 --- a/src/components/toast/hooks/useToastLifecycle.ts +++ b/src/components/toast/hooks/useToastLifecycle.ts @@ -56,7 +56,7 @@ export default function useToastLifecycle({ }); } - if ((isOpening || isRendered) && !isClosing && autoDismissMs > 0) { + if (isRendered && !isClosing && autoDismissMs > 0) { clearTimers(); autoTimerRef.current = window.setTimeout(startDismiss, autoDismissMs); }