-
Notifications
You must be signed in to change notification settings - Fork 3
체크박스 컴포넌트 개발 #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
체크박스 컴포넌트 개발 #9
Changes from all commits
6708a8b
48a7e7d
55529c3
b8056be
0dd1b40
543db63
6c19cf8
f37dc75
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,84 @@ | ||||||||||||||
| 'use client'; | ||||||||||||||
|
|
||||||||||||||
| import clsx from 'clsx'; | ||||||||||||||
| import Image from 'next/image'; | ||||||||||||||
| import type { ChangeEvent, CSSProperties } 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 && label !== undefined && (typeof label !== 'string' || label.trim().length > 0); | ||||||||||||||
| const isReadOnly = options?.readOnly || !onCheckedChange; | ||||||||||||||
| const isDisabled = disabled || isReadOnly; | ||||||||||||||
|
Comment on lines
+33
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
| 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 handleChange = (event: ChangeEvent<HTMLInputElement>) => { | ||||||||||||||
| 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} | ||||||||||||||
| id={id} | ||||||||||||||
| name={name} | ||||||||||||||
| value={value} | ||||||||||||||
| disabled={isDisabled} | ||||||||||||||
| onChange={handleChange} | ||||||||||||||
| readOnly={isReadOnly} | ||||||||||||||
|
Comment on lines
+74
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HTML 표준에 따라
Suggested change
|
||||||||||||||
| /> | ||||||||||||||
| <span className={styles.box} aria-hidden="true"> | ||||||||||||||
| {iconNode} | ||||||||||||||
| </span> | ||||||||||||||
| {hasLabel ? <span className={styles.label}>{label}</span> : null} | ||||||||||||||
| </label> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| 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; | ||
| 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; | ||
| } | ||
| } |
| 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; | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLButtonElement, DropdownItemProps>(function DropdownItem( | ||
| { label, isSelected, size, className, onSelect, tabIndex, onFocus }, | ||
| ref, | ||
| ) { | ||
| return ( | ||
| <button | ||
| ref={ref} | ||
| type="button" | ||
| className={clsx(styles.item, styles[size], isSelected && styles.selected, className)} | ||
| role="option" | ||
| aria-selected={isSelected} | ||
| tabIndex={tabIndex} | ||
| onClick={onSelect} | ||
| onFocus={onFocus} | ||
| > | ||
| {label} | ||
| </button> | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| DropdownItem.displayName = 'DropdownItem'; | ||
|
|
||
| export default DropdownItem; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
가독성 향상을 위해
hasLabel의 조건을 조금 더 간결하게 표현할 수 있습니다.label != null은label !== null && label !== undefined와 동일하게 동작하며,label.trim() !== ''는label.trim().length > 0보다 의도를 더 명확하게 보여줄 수 있습니다.