Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/components/checkbox/CheckBox.tsx
Original file line number Diff line number Diff line change
@@ -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
)
) : (
<Image className={styles.icon} src={iconSrc} alt="" width={boxSize} height={boxSize} />
);

const handleClick = (event: MouseEvent<HTMLInputElement>) => {
if (isReadOnly) {
event.preventDefault();
}
};

const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (isReadOnly && (event.key === ' ' || event.key === 'Enter')) {
event.preventDefault();
}
};

const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
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 (
<label
className={clsx(styles.checkbox, styles[size], isDisabled && styles.disabled, className)}
style={checkboxStyle}
>
<input
className={styles.input}
type="checkbox"
checked={checked}
aria-label={inputAriaLabel}
aria-disabled={isReadOnly || isDisabled ? true : undefined}
Comment thread
Jieunsse marked this conversation as resolved.
id={id}
name={name}
value={value}
disabled={isDisabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
<span className={styles.box} aria-hidden="true">
{iconNode}
</span>
{hasLabel ? <span className={styles.label}>{label}</span> : null}
</label>
);
}
21 changes: 21 additions & 0 deletions src/components/checkbox/constants/styleConstants.ts
Original file line number Diff line number Diff line change
@@ -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;
60 changes: 60 additions & 0 deletions src/components/checkbox/styles/CheckBox.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
39 changes: 39 additions & 0 deletions src/components/checkbox/types/types.ts
Original file line number Diff line number Diff line change
@@ -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<CheckBoxOptions, 'ariaLabel'> & {
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;
});
6 changes: 6 additions & 0 deletions src/types/svg.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module '*.svg' {
import type { StaticImageData } from 'next/image';

const src: StaticImageData;
export default src;
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
"include": [
"next-env.d.ts",
"**/*.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
Expand Down