;
+
+export const Basic: Story = {};
+
+export const WithDefaultValue: Story = {
+ args: {
+ defaultValue: ["banana"],
+ },
+};
+
+export const Orientation: Story = {
+ render: () => {
+ return (
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+ );
+ },
+};
+
+export const GroupDisabled: Story = {
+ args: {
+ disabled: true,
+ defaultValue: ["banana"],
+ label: "전체 그룹 비활성화",
+ },
+};
+
+export const ItemDisabled: Story = {
+ render: () => {
+ return (
+
+
+ 사과
+
+ 바나나 (disabled)
+
+ 오렌지
+
+
+
+ 사과
+
+ 바나나 (disabled)
+
+ 오렌지
+
+
+ );
+ },
+};
+
+export const Tones: Story = {
+ render: () => {
+ return (
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+ );
+ },
+};
+
+export const Invalid: Story = {
+ render: () => {
+ return (
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+
+ 사과
+ 바나나
+ 오렌지
+
+
+ );
+ },
+};
+
+export const Controlled = () => {
+ const [value, setValue] = useState(["apple"]);
+ return (
+
+
setValue(newValue)}
+ >
+ 사과
+ 바나나
+ 오렌지
+
+
+
현재 선택된 값: {value.join(", ")}
+
+
+
+
+ );
+};
diff --git a/src/components/CheckboxGroup/CheckboxGroup.test.tsx b/src/components/CheckboxGroup/CheckboxGroup.test.tsx
new file mode 100644
index 00000000..a080f6cc
--- /dev/null
+++ b/src/components/CheckboxGroup/CheckboxGroup.test.tsx
@@ -0,0 +1,332 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, test, vi } from "vitest";
+import { CheckboxGroup, CheckboxItem } from "./CheckboxGroup";
+
+describe("CheckboxGroup", () => {
+ test("라벨과 자식 요소들을 렌더링한다", () => {
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ expect(screen.getByText("Test Checkbox Group")).toBeInTheDocument();
+ expect(screen.getByText("Option 1")).toBeInTheDocument();
+ expect(screen.getByText("Option 2")).toBeInTheDocument();
+ });
+
+ test("defaultValue가 제공되면 해당 값들을 선택한다", () => {
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ expect(option1).not.toBeChecked();
+ expect(option2).toBeChecked();
+ });
+
+ test("defaultValue가 제공되지 않으면 아무것도 선택하지 않는다", () => {
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ expect(option1).not.toBeChecked();
+ expect(option2).not.toBeChecked();
+ });
+
+ test("여러 값을 defaultValue로 선택할 수 있다", () => {
+ render(
+
+ Option 1
+ Option 2
+ Option 3
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+ const option3 = screen.getByLabelText("Option 3");
+
+ expect(option1).toBeChecked();
+ expect(option2).toBeChecked();
+ expect(option3).not.toBeChecked();
+ });
+
+ test("value가 defaultValue보다 우선한다", () => {
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ expect(option1).not.toBeChecked();
+ expect(option2).toBeChecked();
+ });
+
+ test("disabled가 true일 때 모든 체크박스를 비활성화한다", () => {
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ expect(option1).toBeDisabled();
+ expect(option2).toBeDisabled();
+ });
+
+ test("그룹 disabled가 하위 CheckboxItem의 disabled 스타일을 적용한다", () => {
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ expect(option1).toBeDisabled();
+ expect(option2).toBeDisabled();
+ });
+
+ test("그룹 disabled와 개별 disabled가 모두 적용된다", () => {
+ render(
+
+
+ Option 1
+
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ expect(option1).toBeDisabled();
+ expect(option2).toBeDisabled();
+ });
+
+ test("체크박스 선택 시 onChange를 호출한다", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+
+ await user.click(option1);
+ expect(onChange).toHaveBeenCalledWith(["option1"]);
+ });
+
+ test("여러 체크박스를 선택할 수 있다", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ Option 1
+ Option 2
+ Option 3
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+ const option3 = screen.getByLabelText("Option 3");
+
+ await user.click(option1);
+ expect(option1).toBeChecked();
+ expect(option2).not.toBeChecked();
+ expect(option3).not.toBeChecked();
+
+ await user.click(option2);
+ expect(option1).toBeChecked();
+ expect(option2).toBeChecked();
+ expect(option3).not.toBeChecked();
+
+ await user.click(option3);
+ expect(option1).toBeChecked();
+ expect(option2).toBeChecked();
+ expect(option3).toBeChecked();
+ });
+
+ test("체크박스를 해제할 수 있다", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ expect(option1).toBeChecked();
+ expect(option2).toBeChecked();
+
+ await user.click(option1);
+ expect(option1).not.toBeChecked();
+ expect(option2).toBeChecked();
+ });
+
+ test("제어 모드에서 정상적으로 동작한다", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ const ControlledCheckboxTest = () => {
+ const [selectedValues, setSelectedValues] = useState([
+ "option1",
+ ]);
+
+ return (
+ {
+ onChange(values);
+ setSelectedValues(values);
+ }}
+ >
+ Option 1
+ Option 2
+
+ );
+ };
+
+ render();
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ expect(option1).toBeChecked();
+ expect(option2).not.toBeChecked();
+
+ await user.click(option2);
+ expect(onChange).toHaveBeenCalledWith(["option1", "option2"]);
+ expect(option1).toBeChecked();
+ expect(option2).toBeChecked();
+ });
+});
+
+describe("CheckboxItem", () => {
+ test("여러 체크박스를 독립적으로 선택할 수 있다", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ Option 1
+ Option 2
+ ,
+ );
+
+ const option1 = screen.getByLabelText("Option 1");
+ const option2 = screen.getByLabelText("Option 2");
+
+ await user.click(option1);
+ expect(option1).toBeChecked();
+ expect(option2).not.toBeChecked();
+
+ await user.click(option2);
+ expect(option1).toBeChecked();
+ expect(option2).toBeChecked();
+ });
+
+ test.each([
+ ["Option 1", false],
+ ["Option 2", true],
+ ] as const)(
+ "%s의 disabled 속성을 %s로 올바르게 적용한다",
+ (optionName, isDisabled) => {
+ render(
+
+ Option 1
+
+ Option 2
+
+ ,
+ );
+
+ const option = screen.getByLabelText(optionName);
+
+ if (isDisabled) {
+ expect(option).toBeDisabled();
+ } else {
+ expect(option).not.toBeDisabled();
+ }
+ },
+ );
+
+ test.each([
+ "neutral",
+ "brand",
+ "danger",
+ "warning",
+ "success",
+ "info",
+ ] as const)("%s 톤을 올바르게 렌더링한다", (tone) => {
+ render(
+
+ Option 1
+ ,
+ );
+
+ const checkbox = screen.getByLabelText("Option 1");
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).toHaveAttribute("data-test-tone", tone);
+ });
+});
diff --git a/src/components/CheckboxGroup/CheckboxGroup.tsx b/src/components/CheckboxGroup/CheckboxGroup.tsx
new file mode 100644
index 00000000..6f9ada2d
--- /dev/null
+++ b/src/components/CheckboxGroup/CheckboxGroup.tsx
@@ -0,0 +1,529 @@
+import {
+ Checkbox as ArkCheckbox,
+ type CheckboxCheckedChangeDetails,
+} from "@ark-ui/react/checkbox";
+import { Check } from "lucide-react";
+import { type ReactNode, createContext, useContext, useState } from "react";
+import { css, cva } from "../../../styled-system/css";
+import type { Tone } from "../../tokens/colors";
+
+const CheckboxGroupContext = createContext<{
+ tone: Tone;
+ disabled?: boolean;
+ invalid?: boolean;
+ name: string;
+ selectedValues: string[];
+ onValueChange: (value: string, checked: boolean) => void;
+} | null>(null);
+
+export interface CheckboxGroupProps {
+ /**
+ * 체크박스 요소들
+ */
+ children: ReactNode;
+
+ /**
+ * 동일 그룹의 체크박스들이 공유하는 이름입니다.
+ */
+ name: string;
+
+ /**
+ * 체크박스 그룹을 설명하는 텍스트입니다.
+ */
+ label: string;
+
+ /**
+ * 컴포넌트가 처음 렌더링될 때 선택되는 값들입니다.
+ * @default undefined
+ */
+ defaultValue?: string[];
+
+ /**
+ * 외부에서 선택 값을 직접 제어할 때 사용합니다.
+ * @default undefined
+ */
+ value?: string[];
+
+ /**
+ * 사용자가 선택을 변경할 때 호출되는 콜백입니다.
+ * @default undefined
+ */
+ onChange?: (value: string[]) => void;
+
+ /**
+ * true이면 모든 체크박스가 비활성화되어 상호작용이 불가합니다.
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * 체크박스의 배치 방향입니다. 'horizontal'은 가로, 'vertical'은 세로입니다.
+ * @default undefined
+ */
+ orientation?: "horizontal" | "vertical";
+
+ /**
+ * 색상 강조를 지정합니다.
+ * @default "brand"
+ */
+ tone?: Tone;
+
+ /**
+ * 에러 상태를 지정합니다.
+ * @default false
+ */
+ invalid?: boolean;
+}
+
+/**
+ * 체크박스 그룹 컴포넌트입니다.
+ *
+ * 사용자가 여러 선택지를 선택할 수 있을 때 사용합니다.
+ * 특히 선택지가 2-5개로 적고 모든 옵션을 한눈에 보여주어야 할 때 적합합니다.
+ *
+ * @example
+ *
+ * 사과
+ * 바나나
+ * 오렌지
+ *
+ */
+export function CheckboxGroup({
+ children,
+ name,
+ label,
+ defaultValue,
+ value,
+ onChange,
+ disabled,
+ orientation,
+ tone = "brand",
+ invalid = false,
+}: CheckboxGroupProps) {
+ const isControlled = value !== undefined;
+ const [internalValues, setInternalValues] = useState(
+ defaultValue ?? [],
+ );
+
+ const selectedValues = isControlled ? value : internalValues;
+
+ const handleValueChange = (itemValue: string, checked: boolean) => {
+ const newValues = checked
+ ? [...selectedValues, itemValue]
+ : selectedValues.filter((v) => v !== itemValue);
+
+ if (!isControlled) {
+ setInternalValues(newValues);
+ }
+
+ onChange?.(newValues);
+ };
+
+ return (
+
+
+
+
{children}
+
+
+ );
+}
+
+const checkboxGroupRootStyles = css({
+ display: "flex",
+ flexDirection: "column",
+});
+
+const checkboxGroupStyles = cva({
+ base: {
+ display: "flex",
+ gap: "8",
+ },
+ variants: {
+ orientation: {
+ horizontal: {
+ flexDirection: "row",
+ },
+ vertical: {
+ flexDirection: "column",
+ },
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+});
+
+export interface CheckboxItemProps {
+ /**
+ * 체크박스의 값입니다.
+ */
+ value: string;
+
+ /**
+ * 라벨 등 자식 요소를 표시합니다.
+ */
+ children?: ReactNode;
+
+ /**
+ * true이면 이 체크박스가 비활성화됩니다.
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * DOM 요소에 대한 ref입니다.
+ */
+ ref?: React.Ref;
+}
+
+export function CheckboxItem({
+ value,
+ children,
+ disabled,
+ ref,
+}: CheckboxItemProps) {
+ const context = useContext(CheckboxGroupContext);
+
+ if (!context) {
+ throw new Error(
+ "CheckboxItem 컴포넌트는 CheckboxGroup 내부에서만 사용해야 합니다.",
+ );
+ }
+
+ const {
+ tone,
+ disabled: groupDisabled,
+ invalid: groupInvalid,
+ name,
+ selectedValues,
+ onValueChange,
+ } = context;
+ const isDisabled = disabled || groupDisabled;
+ const isInvalid = groupInvalid;
+ const isChecked = selectedValues.includes(value);
+
+ const handleCheckedChange = (details: CheckboxCheckedChangeDetails) => {
+ const checked = details.checked === true;
+ onValueChange(value, checked);
+ };
+
+ return (
+
+
+
+
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+
+ );
+}
+
+const rootStyles = cva({
+ base: {
+ display: "flex",
+ alignItems: "center",
+ gap: "8",
+ cursor: "pointer",
+ padding: "4",
+ },
+ variants: {
+ disabled: {
+ true: {
+ cursor: "not-allowed",
+ },
+ },
+ },
+});
+
+const controlStyles = cva({
+ base: {
+ position: "relative",
+ flexShrink: 0,
+ width: "16px",
+ height: "16px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "transparent",
+ borderWidth: "2px",
+ borderStyle: "solid",
+ borderColor: "fg.neutral",
+ borderRadius: "sm",
+ transition: "all 0.2s",
+ color: "transparent",
+
+ '&[data-state="checked"]': {
+ color: "fg.neutral",
+ },
+ },
+ variants: {
+ tone: {
+ brand: {
+ ".group:hover &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.brand.hover",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ ".group:active &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.brand.active",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ "&[data-focus-visible]": {
+ outline: "solid",
+ outlineWidth: "lg",
+ outlineColor: "border.brand.focus",
+ outlineOffset: "2",
+ },
+ },
+ success: {
+ ".group:hover &::before, .group:active &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.success",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ "&[data-focus-visible]": {
+ outline: "solid",
+ outlineWidth: "lg",
+ outlineColor: "border.success",
+ outlineOffset: "2",
+ },
+ },
+ warning: {
+ ".group:hover &::before, .group:active &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.warning",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ "&[data-focus-visible]": {
+ outline: "solid",
+ outlineWidth: "lg",
+ outlineColor: "border.warning",
+ outlineOffset: "2",
+ },
+ },
+ info: {
+ ".group:hover &::before, .group:active &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.info",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ "&[data-focus-visible]": {
+ outline: "solid",
+ outlineWidth: "lg",
+ outlineColor: "border.info",
+ outlineOffset: "2",
+ },
+ },
+ danger: {
+ borderColor: "fg.danger",
+ ".group:hover &::before, .group:active &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.danger",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ "&[data-focus-visible]": {
+ outline: "solid",
+ outlineWidth: "lg",
+ outlineColor: "border.danger",
+ outlineOffset: "2",
+ },
+ },
+ neutral: {
+ ".group:hover &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.neutral.hover",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ ".group:active &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.neutral.active",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ "&[data-focus-visible]": {
+ outline: "solid",
+ outlineWidth: "lg",
+ outlineColor: "border.neutral.active",
+ outlineOffset: "2",
+ },
+ },
+ },
+ disabled: {
+ true: {
+ cursor: "not-allowed",
+ borderColor: "fg.neutral.disabled",
+ backgroundColor: "bg.neutral.disabled!",
+ ".group:hover &::before": {
+ display: "none",
+ },
+ '&[data-state="checked"]': {
+ color: "fg.neutral.disabled!",
+ },
+ },
+ },
+ invalid: {
+ true: {
+ borderColor: "fg.danger",
+ ".group:hover &::before": {
+ content: '""',
+ position: "absolute",
+ width: "26px",
+ height: "26px",
+ borderRadius: "8",
+ backgroundColor: "fg.danger",
+ opacity: 0.2,
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: -1,
+ },
+ "&[data-focus-visible]": {
+ outline: "solid",
+ outlineWidth: "lg",
+ outlineColor: "border.danger",
+ outlineOffset: "2",
+ },
+ '&[data-state="checked"]': {
+ color: "fg.danger",
+ },
+ },
+ },
+ },
+});
+
+const indicatorStyles = cva({
+ base: {
+ width: "100%",
+ height: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
+
+const labelTextStyles = cva({
+ base: {
+ fontSize: "md",
+ fontWeight: "semibold",
+ lineHeight: "1.2",
+ color: "fg.neutral",
+ userSelect: "none",
+ },
+ variants: {
+ disabled: {
+ true: {
+ color: "fg.neutral.disabled",
+ },
+ },
+ invalid: {
+ true: {
+ color: "fg.danger",
+ },
+ },
+ },
+});