diff --git a/src/components/CheckboxGroup/CheckboxGroup.stories.tsx b/src/components/CheckboxGroup/CheckboxGroup.stories.tsx new file mode 100644 index 00000000..c397e09b --- /dev/null +++ b/src/components/CheckboxGroup/CheckboxGroup.stories.tsx @@ -0,0 +1,242 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { css } from "../../../styled-system/css"; +import { CheckboxGroup, CheckboxItem } from "./CheckboxGroup"; + +export default { + component: CheckboxGroup, + parameters: { + layout: "centered", + design: { + type: "figma", + url: "https://www.figma.com/design/mQ2ETYC6LXGOwVETov3CgO/Dale-UI-Kit?node-id=851-1768", + }, + }, + args: { + name: "fruits", + label: "좋아하는 과일을 선택하세요 (옵션 선택)", + children: ( + <> + 사과 + 바나나 + 오렌지 + + ), + }, + argTypes: { + children: { + control: false, + }, + }, +} satisfies Meta; + +type Story = StoryObj; + +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", + }, + }, + }, +});