From 6708a8b0b59f3372b189256024243bb683470b16 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 01:25:48 +0900 Subject: [PATCH 1/8] =?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 48a7e7de21a6d66f0e95c8d752b3fc1d2f655bc2 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 16:55:25 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=EC=A0=9C=EB=AF=B8=EB=82=98?= =?UTF-8?q?=EC=9D=B4=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dropdown/Dropdown.tsx | 46 ++++++- src/components/dropdown/DropdownItem.tsx | 21 +-- src/components/dropdown/hooks/useDropdown.ts | 122 +++++++++++++++++- .../dropdown/styles/Dropdown.module.css | 4 + src/components/dropdown/types/types.ts | 3 + src/components/dropdown/utils/dropdown.ts | 2 +- 6 files changed, 179 insertions(+), 19 deletions(-) diff --git a/src/components/dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx index 039427b..4368812 100644 --- a/src/components/dropdown/Dropdown.tsx +++ b/src/components/dropdown/Dropdown.tsx @@ -1,6 +1,7 @@ 'use client'; import clsx from 'clsx'; +import { useEffect, useRef } from 'react'; import DropdownItem from './DropdownItem'; import useDropdown from './hooks/useDropdown'; @@ -18,6 +19,7 @@ export default function Dropdown({ items, defaultValue, value, + placeholder, size = DEFAULT_SIZE, disabled = false, ariaLabel, @@ -27,13 +29,40 @@ export default function Dropdown({ itemClassName, onChange, }: DropdownProps) { - const { listboxId, isOpen, selectedItem, handleSelect, handleToggle, handleBlur, handleKeyDown } = - useDropdown({ items, defaultValue, value, disabled, onChange }); - const triggerAriaLabel = selectedItem ? undefined : ariaLabel; + const { + listboxId, + isOpen, + selectedItem, + activeIndex, + openByKeyboard, + shouldRestoreFocus, + setActiveIndex, + clearRestoreFocus, + handleSelect, + handleToggle, + handleBlur, + handleKeyDown, + } = useDropdown({ items, defaultValue, value, disabled, onChange }); + const triggerAriaLabel = selectedItem || placeholder ? undefined : ariaLabel; + const displayLabel = selectedItem?.label ?? placeholder; + const triggerRef = useRef(null); + const optionRefs = useRef>([]); + + useEffect(() => { + if (!isOpen || !openByKeyboard || activeIndex < 0) return; + optionRefs.current[activeIndex]?.focus(); + }, [activeIndex, isOpen, openByKeyboard]); + + useEffect(() => { + if (isOpen || !shouldRestoreFocus) return; + triggerRef.current?.focus(); + clearRestoreFocus(); + }, [clearRestoreFocus, isOpen, shouldRestoreFocus]); return (
diff --git a/src/components/dropdown/DropdownItem.tsx b/src/components/dropdown/DropdownItem.tsx index 526cd12..402e1dc 100644 --- a/src/components/dropdown/DropdownItem.tsx +++ b/src/components/dropdown/DropdownItem.tsx @@ -1,24 +1,29 @@ import clsx from 'clsx'; +import { forwardRef } from 'react'; import styles from './styles/DropdownItem.module.css'; import type { DropdownItemProps } from './types/types'; -export default function DropdownItem({ - label, - isSelected, - size, - className, - onSelect, -}: DropdownItemProps) { +const DropdownItem = forwardRef(function DropdownItem( + { label, isSelected, size, className, onSelect, tabIndex, onFocus }, + ref, +) { return ( ); -} +}); + +DropdownItem.displayName = 'DropdownItem'; + +export default DropdownItem; diff --git a/src/components/dropdown/hooks/useDropdown.ts b/src/components/dropdown/hooks/useDropdown.ts index bcfdb2f..3650c0a 100644 --- a/src/components/dropdown/hooks/useDropdown.ts +++ b/src/components/dropdown/hooks/useDropdown.ts @@ -1,5 +1,5 @@ import type { FocusEvent, KeyboardEvent } from 'react'; -import { useId, useState } from 'react'; +import { useId, useMemo, useState } from 'react'; import type { UseDropdownOptions } from '../types/types'; import { getSelectedItem, resolveInitialValue } from '../utils/dropdown'; @@ -13,12 +13,29 @@ export default function useDropdown({ }: UseDropdownOptions) { const listboxId = useId(); const [isOpen, setIsOpen] = useState(false); + const [openByKeyboard, setOpenByKeyboard] = useState(false); + const [shouldRestoreFocus, setShouldRestoreFocus] = useState(false); const [uncontrolledValue, setUncontrolledValue] = useState(() => resolveInitialValue(items, defaultValue), ); + const [activeIndex, setActiveIndex] = useState(null); const currentValue = value ?? uncontrolledValue; const selectedItem = getSelectedItem(items, currentValue); + const selectedIndex = useMemo( + () => items.findIndex((item) => item.value === currentValue), + [items, currentValue], + ); + const fallbackIndex = useMemo( + () => (selectedIndex >= 0 ? selectedIndex : items.length > 0 ? 0 : -1), + [items.length, selectedIndex], + ); + const resolvedActiveIndex = useMemo(() => { + if (items.length === 0) return -1; + const base = activeIndex ?? fallbackIndex; + if (base < 0) return 0; + return Math.min(base, items.length - 1); + }, [activeIndex, fallbackIndex, items.length]); const handleSelect = (nextValue: string) => { if (disabled) return; @@ -27,29 +44,124 @@ export default function useDropdown({ } onChange?.(nextValue); setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(true); }; const handleToggle = () => { if (disabled) return; - setIsOpen((prev) => !prev); + setIsOpen((prev) => { + const next = !prev; + if (next) { + setOpenByKeyboard(false); + setActiveIndex(fallbackIndex); + } else { + setActiveIndex(null); + } + return next; + }); }; const handleBlur = (event: FocusEvent) => { const nextFocus = event.relatedTarget as Node | null; if (nextFocus && event.currentTarget.contains(nextFocus)) return; setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(false); }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return; - event.stopPropagation(); - setIsOpen(false); + if (disabled) return; + const target = event.target as HTMLElement | null; + const isTrigger = + target instanceof HTMLButtonElement && target.getAttribute('aria-haspopup') === 'listbox'; + + if (items.length === 0) { + if (event.key === 'Escape') { + event.stopPropagation(); + setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(true); + } + return; + } + + if (!isOpen) { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setIsOpen(true); + setOpenByKeyboard(true); + setActiveIndex(fallbackIndex); + } + return; + } + + if (isTrigger) { + if (event.key === 'Escape') { + event.stopPropagation(); + setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(true); + } + return; + } + + const maxIndex = items.length - 1; + setOpenByKeyboard(true); + + switch (event.key) { + case 'ArrowDown': { + event.preventDefault(); + const nextIndex = resolvedActiveIndex + 1 > maxIndex ? 0 : resolvedActiveIndex + 1; + setActiveIndex(nextIndex); + break; + } + case 'ArrowUp': { + event.preventDefault(); + const nextIndex = resolvedActiveIndex <= 0 ? maxIndex : resolvedActiveIndex - 1; + setActiveIndex(nextIndex); + break; + } + case 'Home': { + event.preventDefault(); + setActiveIndex(0); + break; + } + case 'End': { + event.preventDefault(); + setActiveIndex(maxIndex); + break; + } + case 'Enter': + case ' ': { + event.preventDefault(); + const item = items[resolvedActiveIndex]; + if (item) { + handleSelect(item.value); + } + break; + } + case 'Escape': { + event.stopPropagation(); + setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(true); + break; + } + default: + break; + } }; return { listboxId, isOpen, selectedItem, + activeIndex: resolvedActiveIndex, + openByKeyboard, + shouldRestoreFocus, + setActiveIndex, + clearRestoreFocus: () => setShouldRestoreFocus(false), handleSelect, handleToggle, handleBlur, diff --git a/src/components/dropdown/styles/Dropdown.module.css b/src/components/dropdown/styles/Dropdown.module.css index 56d9ecf..5508514 100644 --- a/src/components/dropdown/styles/Dropdown.module.css +++ b/src/components/dropdown/styles/Dropdown.module.css @@ -36,6 +36,10 @@ white-space: nowrap; } +.placeholder { + color: var(--Text-Placeholder, #94a3b8); +} + .icon { display: inline-flex; align-items: center; diff --git a/src/components/dropdown/types/types.ts b/src/components/dropdown/types/types.ts index d86ef29..75b086d 100644 --- a/src/components/dropdown/types/types.ts +++ b/src/components/dropdown/types/types.ts @@ -11,6 +11,7 @@ export interface DropdownProps { items: DropdownItemData[]; defaultValue?: string; value?: string; + placeholder?: string; size?: DropdownMenuSize; disabled?: boolean; ariaLabel?: string; @@ -27,6 +28,8 @@ export interface DropdownItemProps { size: DropdownMenuSize; className?: string; onSelect: () => void; + tabIndex?: number; + onFocus?: () => void; } export interface UseDropdownOptions { diff --git a/src/components/dropdown/utils/dropdown.ts b/src/components/dropdown/utils/dropdown.ts index 2400816..e55ed9a 100644 --- a/src/components/dropdown/utils/dropdown.ts +++ b/src/components/dropdown/utils/dropdown.ts @@ -8,5 +8,5 @@ export function resolveInitialValue(items: DropdownItemData[], defaultValue?: st } export function getSelectedItem(items: DropdownItemData[], currentValue: string) { - return items.find((item) => item.value === currentValue) ?? items[0]; + return items.find((item) => item.value === currentValue); } From 55529c31acd3707ed5c15493588eb98e0fa85b75 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 02:15:18 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83ddd7a..3d4815f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,17 +14,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 9 - - name: Install dependencies run: pnpm install --frozen-lockfile From b8056bead076ce2283ae0af613a22c12279d82d5 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 30 Jan 2026 15:28:31 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=95=84=EC=9D=B4=EC=BD=98=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icons/check/{nonChekcedLarge.svg => nonCheckedLarge.svg} | 0 .../icons/check/{nonChekcedSmall.svg => nonCheckedSmall.svg} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/assets/icons/check/{nonChekcedLarge.svg => nonCheckedLarge.svg} (100%) rename src/assets/icons/check/{nonChekcedSmall.svg => nonCheckedSmall.svg} (100%) diff --git a/src/assets/icons/check/nonChekcedLarge.svg b/src/assets/icons/check/nonCheckedLarge.svg similarity index 100% rename from src/assets/icons/check/nonChekcedLarge.svg rename to src/assets/icons/check/nonCheckedLarge.svg diff --git a/src/assets/icons/check/nonChekcedSmall.svg b/src/assets/icons/check/nonCheckedSmall.svg similarity index 100% rename from src/assets/icons/check/nonChekcedSmall.svg rename to src/assets/icons/check/nonCheckedSmall.svg From 0dd1b40bbd48603fbebc1d6b9c44fded466a3cf8 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 30 Jan 2026 15:28:58 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20constants.ts=20->=20styleConstants?= =?UTF-8?q?.ts=EB=A1=9C=20=EC=88=98=EC=A0=95=ED=95=B4=EC=84=9C=20=EC=97=AD?= =?UTF-8?q?=ED=95=A0=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checkbox/constants/constants.ts | 20 ------------------ .../checkbox/constants/styleConstants.ts | 21 +++++++++++++++++++ 2 files changed, 21 insertions(+), 20 deletions(-) delete mode 100644 src/components/checkbox/constants/constants.ts create mode 100644 src/components/checkbox/constants/styleConstants.ts diff --git a/src/components/checkbox/constants/constants.ts b/src/components/checkbox/constants/constants.ts deleted file mode 100644 index 3085b5c..0000000 --- a/src/components/checkbox/constants/constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/constants/styleConstants.ts b/src/components/checkbox/constants/styleConstants.ts new file mode 100644 index 0000000..9074ba7 --- /dev/null +++ b/src/components/checkbox/constants/styleConstants.ts @@ -0,0 +1,21 @@ +import checkedLarge from '@/assets/icons/check/checkedLarge.svg'; +import checkedSmall from '@/assets/icons/check/checkedSmall.svg'; +import nonCheckedLarge from '@/assets/icons/check/nonCheckedLarge.svg'; +import nonCheckedSmall from '@/assets/icons/check/nonCheckedSmall.svg'; + +export const CHECKBOX_STYLE = { + icons: { + checked: { + large: checkedLarge, + small: checkedSmall, + }, + unchecked: { + large: nonCheckedLarge, + small: nonCheckedSmall, + }, + }, + boxSize: { + large: 18, + small: 16, + }, +} as const; From 543db63bbdfd28a891eebed5b21e9ed82aecd9d8 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 30 Jan 2026 15:29:22 +0900 Subject: [PATCH 6/8] =?UTF-8?q?style:=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=82=AC=EC=9D=B4=EC=A6=88=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/checkbox/styles/CheckBox.module.css | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/components/checkbox/styles/CheckBox.module.css b/src/components/checkbox/styles/CheckBox.module.css index af6a392..937baeb 100644 --- a/src/components/checkbox/styles/CheckBox.module.css +++ b/src/components/checkbox/styles/CheckBox.module.css @@ -25,16 +25,11 @@ display: inline-flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; + width: var(--checkbox-box-size, 18px); + height: var(--checkbox-box-size, 18px); flex-shrink: 0; } -.small .box { - width: 16px; - height: 16px; -} - .icon { width: 100%; height: 100%; From 6c19cf84aefbb7aec8739717333e0c1b6dc1a9d6 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 30 Jan 2026 15:29:47 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=99=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/checkbox/types/types.ts | 41 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/components/checkbox/types/types.ts b/src/components/checkbox/types/types.ts index 18a1ccc..2afe493 100644 --- a/src/components/checkbox/types/types.ts +++ b/src/components/checkbox/types/types.ts @@ -1,24 +1,39 @@ +import type { ReactNode } from 'react'; + export type CheckBoxSize = 'large' | 'small'; +export type CheckBoxIconSet = { + checked: ReactNode; + unchecked: ReactNode; +}; + +export type CheckBoxOptions = { + ariaLabel?: string; + readOnly?: boolean; + icons?: CheckBoxIconSet; +}; + +export type CheckBoxOptionsWithAriaLabel = Omit & { + ariaLabel: string; +}; + interface CheckBoxBaseProps { - isChecked: boolean; + checked: 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; + onCheckedChange?: (checked: boolean) => void; } -export type CheckBoxProps = CheckBoxPropsWithLabel | CheckBoxPropsWithAriaLabel; +export type CheckBoxProps = + | (CheckBoxBaseProps & { + label: ReactNode; + options?: CheckBoxOptions; + }) + | (CheckBoxBaseProps & { + label?: undefined; + options: CheckBoxOptionsWithAriaLabel; + }); From f37dc755db0b8a71014e4e1e117c62b3b6063812 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Fri, 30 Jan 2026 15:30:55 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20use=20client=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9,=20jsDoc=20=EC=84=A4=EB=AA=85=20=EA=B0=9C=EC=84=A0,?= =?UTF-8?q?=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/checkbox/CheckBox.tsx | 65 ++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/src/components/checkbox/CheckBox.tsx b/src/components/checkbox/CheckBox.tsx index f3630de..02874ae 100644 --- a/src/components/checkbox/CheckBox.tsx +++ b/src/components/checkbox/CheckBox.tsx @@ -1,57 +1,84 @@ +'use client'; + import clsx from 'clsx'; import Image from 'next/image'; -import type { ChangeEvent } from 'react'; +import type { ChangeEvent, CSSProperties } from 'react'; import styles from './styles/CheckBox.module.css'; -import { CHECKBOX_ICON, ICON_SIZE } from './constants/constants'; +import { CHECKBOX_STYLE } from './constants/styleConstants'; import type { CheckBoxProps } from './types/types'; /** * 체크박스 컴포넌트. - * @param isChecked 체크 여부(필수) - * @param onChange 체크 상태 변경 콜백 (필요 시) + * @param checked 체크 여부 + * @param onCheckedChange 체크 상태 변경 콜백 * @param size 체크박스 크기('large' | 'small') - * @param label 표시 라벨(없으면 ariaLabel 필수) - * @param ariaLabel 라벨이 없을 때 사용하는 접근성 라벨(필수) + * @param label 접근성 용도의 라벨(없으면 options.ariaLabel 필수) + * @param options 고급 옵션(ariaLabel/readOnly/icons) */ export default function CheckBox({ - isChecked, + checked, size = 'large', label, - ariaLabel, id, name, value, disabled = false, className, - onChange, + options, + onCheckedChange, }: CheckBoxProps) { - const iconSrc = isChecked ? CHECKBOX_ICON.checked[size] : CHECKBOX_ICON.unchecked[size]; - const iconSize = ICON_SIZE[size]; - const inputAriaLabel = label ? undefined : ariaLabel; + const hasLabel = + label !== null && label !== undefined && (typeof label !== 'string' || label.trim().length > 0); + const isReadOnly = options?.readOnly || !onCheckedChange; + const isDisabled = disabled || isReadOnly; + const iconSrc = checked + ? CHECKBOX_STYLE.icons.checked[size] + : CHECKBOX_STYLE.icons.unchecked[size]; + const boxSize = CHECKBOX_STYLE.boxSize[size]; + const inputAriaLabel = hasLabel ? undefined : options?.ariaLabel; + const checkboxStyle = { + '--checkbox-box-size': `${boxSize}px`, + } as CSSProperties; + const iconNode = options?.icons ? ( + checked ? ( + options.icons.checked + ) : ( + options.icons.unchecked + ) + ) : ( + + ); const handleChange = (event: ChangeEvent) => { - onChange?.(event.target.checked); + onCheckedChange?.(event.target.checked); }; + if (process.env.NODE_ENV !== 'production' && !hasLabel && !options?.ariaLabel) { + console.warn('CheckBox: label이 비어있다면 ariaLabel이 필요합니다.'); + } + return ( -