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
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..02874ae
--- /dev/null
+++ b/src/components/checkbox/CheckBox.tsx
@@ -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;
+ 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) => {
+ 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/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);
}