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 diff --git a/src/components/checkbox/CheckBox.tsx b/src/components/checkbox/CheckBox.tsx new file mode 100644 index 0000000..879f1d1 --- /dev/null +++ b/src/components/checkbox/CheckBox.tsx @@ -0,0 +1,101 @@ +'use client'; + +import clsx from 'clsx'; +import Image from 'next/image'; +import type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent } from 'react'; + +import styles from './styles/CheckBox.module.css'; +import { CHECKBOX_STYLE } from './constants/styleConstants'; +import type { CheckBoxProps } from './types/types'; + +/** + * 체크박스 컴포넌트. + * @param checked 체크 여부 + * @param onCheckedChange 체크 상태 변경 콜백 + * @param size 체크박스 크기('large' | 'small') + * @param label 접근성 용도의 라벨(없으면 options.ariaLabel 필수) + * @param options 고급 옵션(ariaLabel/readOnly/icons) + */ +export default function CheckBox({ + checked, + size = 'large', + label, + id, + name, + value, + disabled = false, + className, + options, + onCheckedChange, +}: CheckBoxProps) { +const hasLabel = label != null && (typeof label === 'string' ? label.trim() !== '' : true); + const isReadOnly = options?.readOnly || !onCheckedChange; + const isDisabled = disabled; + 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 handleClick = (event: MouseEvent) => { + if (isReadOnly) { + event.preventDefault(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (isReadOnly && (event.key === ' ' || event.key === 'Enter')) { + event.preventDefault(); + } + }; + + const handleChange = (event: ChangeEvent) => { + if (isReadOnly || isDisabled) { + event.preventDefault(); + return; + } + onCheckedChange?.(event.target.checked); + }; + + if (process.env.NODE_ENV !== 'production' && !hasLabel && !options?.ariaLabel) { + console.warn('CheckBox: label이 비어있다면 ariaLabel이 필요합니다.'); + } + + return ( + + ); +} 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; diff --git a/src/components/checkbox/styles/CheckBox.module.css b/src/components/checkbox/styles/CheckBox.module.css new file mode 100644 index 0000000..937baeb --- /dev/null +++ b/src/components/checkbox/styles/CheckBox.module.css @@ -0,0 +1,60 @@ +.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: var(--checkbox-box-size, 18px); + height: var(--checkbox-box-size, 18px); + flex-shrink: 0; +} + +.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..2afe493 --- /dev/null +++ b/src/components/checkbox/types/types.ts @@ -0,0 +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 { + checked: boolean; + size?: CheckBoxSize; + id?: string; + name?: string; + value?: string; + disabled?: boolean; + className?: string; + onCheckedChange?: (checked: boolean) => void; +} + +export type CheckBoxProps = + | (CheckBoxBaseProps & { + label: ReactNode; + options?: CheckBoxOptions; + }) + | (CheckBoxBaseProps & { + label?: undefined; + options: CheckBoxOptionsWithAriaLabel; + }); diff --git a/src/types/svg.d.ts b/src/types/svg.d.ts new file mode 100644 index 0000000..b49d15d --- /dev/null +++ b/src/types/svg.d.ts @@ -0,0 +1,6 @@ +declare module '*.svg' { + import type { StaticImageData } from 'next/image'; + + const src: StaticImageData; + export default src; +} diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..6082465 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ }, "include": [ "next-env.d.ts", + "**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts",