Skip to content
Closed
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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
84 changes: 84 additions & 0 deletions src/components/checkbox/CheckBox.tsx
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);
Comment on lines +31 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

가독성 향상을 위해 hasLabel의 조건을 조금 더 간결하게 표현할 수 있습니다. label != nulllabel !== null && label !== undefined와 동일하게 동작하며, label.trim() !== ''label.trim().length > 0보다 의도를 더 명확하게 보여줄 수 있습니다.

Suggested change
const hasLabel =
label !== null && label !== undefined && (typeof label !== 'string' || label.trim().length > 0);
const hasLabel =
label != null && (typeof label !== 'string' || label.trim() !== '');

const isReadOnly = options?.readOnly || !onCheckedChange;
const isDisabled = disabled || isReadOnly;
Comment on lines +33 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

readOnly 상태(e.g., onCheckedChange prop이 없을 때)가 체크박스를 disabled 상태로 만드는 것은 접근성 문제를 야기할 수 있습니다. readOnly 요소는 키보드 포커스가 가능해야 스크린 리더 사용자가 값을 읽을 수 있습니다. isReadOnlyisDisabled의 로직을 분리하는 것이 좋습니다.

Suggested change
const isReadOnly = options?.readOnly || !onCheckedChange;
const isDisabled = disabled || isReadOnly;
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 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

HTML 표준에 따라 <input type="checkbox">readOnly 속성을 지원하지 않습니다. 접근성을 위해 aria-readonly 속성을 사용해야 합니다. 또한, disabled 속성은 위에서 분리한 isDisabled 값만 사용하도록 수정합니다.

Suggested change
disabled={isDisabled}
onChange={handleChange}
readOnly={isReadOnly}
disabled={isDisabled}
onChange={handleChange}
aria-readonly={isReadOnly}

/>
<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';

Check failure on line 1 in src/components/checkbox/constants/styleConstants.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find module '@/assets/icons/check/checkedLarge.svg' or its corresponding type declarations.
import checkedSmall from '@/assets/icons/check/checkedSmall.svg';

Check failure on line 2 in src/components/checkbox/constants/styleConstants.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find module '@/assets/icons/check/checkedSmall.svg' or its corresponding type declarations.
import nonCheckedLarge from '@/assets/icons/check/nonCheckedLarge.svg';

Check failure on line 3 in src/components/checkbox/constants/styleConstants.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find module '@/assets/icons/check/nonCheckedLarge.svg' or its corresponding type declarations.
import nonCheckedSmall from '@/assets/icons/check/nonCheckedSmall.svg';

Check failure on line 4 in src/components/checkbox/constants/styleConstants.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find module '@/assets/icons/check/nonCheckedSmall.svg' or its corresponding type declarations.

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;
});
46 changes: 41 additions & 5 deletions src/components/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import clsx from 'clsx';
import { useEffect, useRef } from 'react';

import DropdownItem from './DropdownItem';
import useDropdown from './hooks/useDropdown';
Expand All @@ -18,6 +19,7 @@ export default function Dropdown({
items,
defaultValue,
value,
placeholder,
size = DEFAULT_SIZE,
disabled = false,
ariaLabel,
Expand All @@ -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<HTMLButtonElement | null>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);

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 (
<div className={clsx(styles.dropdown, className)} onBlur={handleBlur} onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
type="button"
className={clsx(styles.button, disabled && styles.disabled, buttonClassName)}
aria-haspopup="listbox"
Expand All @@ -43,7 +72,9 @@ export default function Dropdown({
disabled={disabled}
onClick={handleToggle}
>
<span className={styles.label}>{selectedItem?.label}</span>
<span className={clsx(styles.label, !selectedItem && placeholder && styles.placeholder)}>
{displayLabel}
</span>
<span className={clsx(styles.icon, isOpen && styles.iconOpen)} aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3.5 6L8 10.5L12.5 6" stroke="currentColor" strokeWidth="1.6" />
Expand All @@ -57,14 +88,19 @@ export default function Dropdown({
role="listbox"
aria-label={ariaLabel}
>
{items.map((item) => (
{items.map((item, index) => (
<DropdownItem
key={item.value}
label={item.label}
isSelected={item.value === selectedItem?.value}
size={size}
className={itemClassName}
tabIndex={openByKeyboard && activeIndex === index ? 0 : -1}
onFocus={() => setActiveIndex(index)}
onSelect={() => handleSelect(item.value)}
ref={(node) => {
optionRefs.current[index] = node;
}}
/>
))}
</div>
Expand Down
21 changes: 13 additions & 8 deletions src/components/dropdown/DropdownItem.tsx
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;
Loading
Loading